Add iOS app implementation plan: document prerequisites, scaffolding, platform specifics, push notification setup, and App Store review considerations. Update cross-references in development and mobile documentation.

This commit is contained in:
MartinBraquet
2026-07-29 14:33:06 +02:00
parent 33b047b02c
commit ef5f8dee4d
3 changed files with 318 additions and 0 deletions

View File

@@ -110,3 +110,4 @@ Cross-cutting: [`docs/internationalization.md`](docs/internationalization.md),
[`docs/profile-fields.md`](docs/profile-fields.md), [`docs/testing.md`](docs/testing.md),
[`docs/logging-monitoring.md`](docs/logging-monitoring.md),
[`docs/troubleshooting.md`](docs/troubleshooting.md).
Mobile: [`android/README.md`](android/README.md), [`docs/ios.md`](docs/ios.md) (iOS plan, not built yet).

View File

@@ -17,3 +17,4 @@ See those other useful documents as well:
- [troubleshooting.md](troubleshooting.md) for resolving common development issues
- [profile-fields.md](profile-fields.md) for adding new profile fields
- [internationalization.md](internationalization.md) for adding new languages
- [ios.md](ios.md) for the plan to ship the iOS app (not built yet)

316
docs/ios.md Normal file
View File

@@ -0,0 +1,316 @@
# Implementing the iOS app
Implementation notes for shipping Compass on iOS. Nothing here is built yet — this is the plan, written
against what the Android app ([`android/README.md`](../android/README.md),
[`android/CLAUDE.md`](../android/CLAUDE.md)) already does, so the work is framed as "what carries over"
vs. "what has no iOS equivalent yet".
The strategy is the same as Android: **Capacitor wrapper around the existing Next.js static export**. No
React Native, no second UI codebase. `web/` stays the single source of the product.
---
## 1. What already works, unchanged
These are not iOS work items — they're already platform-agnostic and will light up as soon as an iOS
target exists:
| Piece | Where |
| ------------------------------------------------ | --------------------------------------------------- |
| The whole UI | `web/` — same static export as Android |
| Static-export build (strips SSR/ISR/SSG) | `scripts/build_web_view.sh` |
| Native-platform detection | `web/lib/util/webview.ts` (`isAndroidApp` — rename) |
| Safe-area insets (notch / home indicator) | `web/styles/globals.css` `env(safe-area-inset-*)` |
| Status-bar theming | `web/hooks/use-theme.ts` (`updateStatusBar`) |
| Keyboard show/hide handling | `web/pages/_app.tsx` (`@capacitor/keyboard`) |
| Native share sheet | `web/lib/util/share.ts` (`@capacitor/share`) |
| Push registration + token save | `web/lib/service/android-push.ts` (rename) |
| `save-subscription-mobile` endpoint + FCM tokens | `backend/api/src/save-subscription-mobile.ts` |
The Capacitor plugins we already depend on (`@capacitor/app`, `keyboard`, `push-notifications`, `share`,
`status-bar`, `@capgo/capacitor-social-login`) all support iOS. Nothing needs replacing.
### Naming cleanup to do first
`isAndroidApp()`, `AndroidPush`, `android-push.ts` are all misnomers the moment iOS exists — they already
mean "native app". Rename to `isNativeApp()` / `NativePush` / `native-push.ts` before adding the platform,
and use `Capacitor.getPlatform()` (`'ios' | 'android' | 'web'`) wherever behaviour genuinely diverges.
`isNativeMobile()` in `web/lib/util/webview.ts` is already the right name and can stay.
---
## 2. Prerequisites (hard blockers)
- **A Mac.** Xcode is macOS-only, and there is no supported way to build or sign an iOS app without it.
This includes CI: GitHub Actions needs a `macos-latest` runner (billed at 10× Linux minutes).
- **Apple Developer Program membership** — $99/year. Not just for shipping: the Push Notifications and
Sign in with Apple **entitlements are unavailable on a free account**, so the two features that make
this more than a wrapped website can't even be built without it.
- **A physical iPhone** — see below. The Simulator is not sufficient.
- Xcode 16+, CocoaPods (`sudo gem install cocoapods`), Node 22+, Java is _not_ needed.
### Test device
Buy one. The Simulator can inject a local `.apns` payload into a running app, which validates our tap
handler, but it **cannot obtain a real APNs device token**, so it can't exercise the part we actually need
to trust: `PushNotifications.register()` → token → `save-subscription-mobile``sendPushToToken`
delivery. Everything in §6 is untestable without hardware.
Hardware specs are close to irrelevant here — the app is a WKWebView, and the push plugin needs no Face ID,
no Dynamic Island, no particular chip. Two things about the device do matter:
- **It must have a notch or Dynamic Island.** Content sitting under the status bar is the single most
common layout bug in a webview app, and our layout leans hard on `env(safe-area-inset-*)`
(`web/styles/globals.css`, `bottom-nav-bar.tsx`, `filters.tsx`, `search.tsx`, `media-modal.tsx`). A
device without one gives a bottom inset of `0px` and never surfaces those bugs.
- **It must run a current iOS**, so the permission dialogs and APNs behaviour match what users see.
**Recommendation: a refurbished iPhone 12 or 13, ~$150250.** Notch, current iOS, cheap. That's the whole
requirement.
**Avoid the iPhone SE (2nd/3rd gen)** even though it's often the cheapest option — Home-button body, no
notch, smaller screen. It would miss exactly the class of bug the device is being bought to catch, to save
$50100. False economy.
Dynamic Island phones (14 Pro and later) have slightly larger top insets than notched ones, but since
everything is driven by `env()` rather than hardcoded values, a notched device is a fine proxy. Check the
top of the profile page and the filter sheet on whatever you get.
---
## 3. Scaffolding the platform
```bash
yarn --cwd=web add -D @capacitor/ios
npx cap add ios # creates ios/App/… at the repo root, alongside android/
yarn build-web-view
npx cap sync ios
npx cap open ios # opens ios/App/App.xcworkspace in Xcode
```
`capacitor.config.ts` at the repo root is shared — `appId`, `appName`, `webDir: 'web/out'` and
`includePlugins` all apply to both platforms as-is. Two things to add:
```ts
ios: {
contentInset: 'always', // avoids WKWebView double-insetting under the notch
scheme: 'Compass', // app is served from capacitor://; see §6 on cookies/CORS
},
```
The dev-server override (`server: {url: 'http://10.0.2.2:3000', cleartext: true}`) is Android-specific:
`10.0.2.2` is the Android emulator's alias for the host. The iOS Simulator shares the host's network, so it
should use `localhost:3000`; a physical iPhone needs the LAN IP, same as
`NEXT_PUBLIC_WEBVIEW_DEV_PHONE=1` already does. Branch on `process.env.CAP_PLATFORM` or just add an
`ios.url` when we wire this up. Cleartext HTTP also needs an ATS exception in `Info.plist`
(`NSAllowsLocalNetworking`) — **debug configuration only**, App Review rejects a blanket
`NSAllowsArbitraryLoads`.
Also register the repo-root `ios/` directory in `.gitignore` carefully: commit `ios/App/App.xcodeproj`,
`Info.plist`, and the source, but ignore `ios/App/Pods/` and `ios/App/build/` (mirror what
`android/.gitignore` does).
---
## 4. Native code with no iOS equivalent yet
`android/app/src/main/java/com/compassconnections/app/MainActivity.java` has grown four hand-written
native features. Each needs a decision on iOS:
### 4.1 Deep-link bridge (`handleAppLink`)
Android stashes the launch `Intent` URL in `pendingDeepLink`, exposes it over a
`@JavascriptInterface` (`window.AndroidBridge.getPendingDeepLink()`), and pushes later links in by calling
`evaluateJavascript("handleAppLink(...)")` from `onNewIntent`. `web/pages/_app.tsx:198-209` consumes both
paths.
On iOS **don't reimplement the bridge**`@capacitor/app` already gives you this cross-platform:
```ts
App.addListener('appUrlOpen', ({url}) => handleAppLink({endpoint: new URL(url).pathname}))
const launch = await App.getLaunchUrl() // replaces getPendingDeepLink()
```
Ideally migrate Android onto the same listener afterwards and delete the `AndroidBridge` deep-link half.
Universal Links (the iOS equivalent of the `autoVerify` intent filter for `compassmeet.com`) need:
- the **Associated Domains** capability with `applinks:compassmeet.com` and `applinks:www.compassmeet.com`,
- an `apple-app-site-association` JSON file served from `https://compassmeet.com/.well-known/`, no
redirect, `Content-Type: application/json`. Add it to `web/public/.well-known/` and confirm the Vercel
config doesn't rewrite it.
### 4.2 `downloadFile` (data export)
`web/components/settings/general-settings.tsx:353` calls `window.AndroidBridge.downloadFile(...)` because
Android's WebView won't honour a blob download. WKWebView on iOS 14+ _does_ handle
`<a download>` / blob URLs and hands off to the share sheet. Simplest path: keep the `AndroidBridge`
branch for Android, and on iOS fall through to `@capacitor/share` or `@capacitor/filesystem`
(`Directory.Documents` + `Share.share({url})`). Don't write a Swift `WKScriptMessageHandler` unless that
fails in testing.
### 4.3 In-app update prompt
`AppUpdateManagerFactory` / `AppUpdateType.IMMEDIATE` is Play-Store-only and **has no iOS counterpart**
Apple forbids apps from forcing their own updates. The iOS equivalent is either nothing (users update via
the App Store) or a soft version check: query a `min-supported-version` value from the API on launch and
render an in-app "please update" screen linking to the App Store. Ship without it first.
### 4.4 Google Sign-In `onActivityResult` plumbing
The `GoogleProvider.REQUEST_AUTHORIZE_GOOGLE_*` handling in `MainActivity` and the
`ModifiedMainActivityForSocialLoginPlugin` interface are the Android-specific half of
`@capgo/capacitor-social-login`. On iOS the plugin needs instead:
- an **iOS OAuth client ID** in Google Cloud Console (we currently only have
`WEB_GOOGLE_CLIENT_ID` in `common/src/constants.ts:48`; the commented-out `ANDROID_GOOGLE_CLIENT_ID`
shows the shape),
- the reversed client ID registered as a `CFBundleURLSchemes` entry in `Info.plist`,
- `SocialLogin.initialize({google: {webClientId, iOSClientId}})` in
`web/lib/firebase/users.ts:93`.
The rest of `googleNativeLogin()` (exchange `idToken``signInWithCredential`) is unchanged.
---
## 5. Sign in with Apple (required, new work)
App Store Review Guideline **4.8** requires Sign in with Apple in any app that offers third-party social
login — which we do. This is not optional and is a common first-submission rejection.
Work involved:
1. Enable the **Sign in with Apple** capability in Xcode and on the App ID.
2. Enable the Apple provider in Firebase Console → Authentication, register the Services ID and key.
3. Add an Apple button to the login UI, gated on `Capacitor.getPlatform() === 'ios'`.
4. `@capgo/capacitor-social-login` supports `provider: 'apple'` — reuse the `googleNativeLogin` shape and
`signInWithCredential(auth, OAuthProvider('apple.com').credential({idToken, rawNonce}))`.
5. Apple's **private relay emails** (`…@privaterelay.appleid.com`) are real and deliverable but forwarded.
Check that onboarding, `backend/email/` sends, and any email-uniqueness logic tolerate them, and that
we handle the "name is only returned on the very first authorization" quirk — if we drop it, the user
has no name and Apple will never send it again.
---
## 6. Push notifications (APNs)
`@capacitor/push-notifications` is already wired in `web/lib/service/android-push.ts` and works on iOS, but
the transport underneath is different and the **backend payload is currently Android-only**.
Setup:
1. Push Notifications capability + `aps-environment` entitlement in Xcode.
2. Create an APNs **auth key** (`.p8`, preferred over certs — doesn't expire) in the Apple Developer
portal, upload it to Firebase Console → Project Settings → Cloud Messaging, with Team ID and Key ID.
Then FCM tokens keep working and no backend token-storage change is needed
(`push_subscriptions_mobile` stays as-is).
3. Add the iOS app (bundle ID `com.compassconnections.app`) to the Firebase project and drop
`GoogleService-Info.plist` into `ios/App/App/`.
**Backend change required.** `sendPushToToken` in `backend/shared/src/mobile.ts:96` builds a `TokenMessage`
with an `android.notification` block and a bare `data: {endpoint}`. Sent to an iOS token as-is, that is a
_data-only_ push: it will not display anything and is delivered at low priority or not at all. Add:
```ts
apns: {
payload: {aps: {alert: {title: payload.title, body: payload.body}, sound: 'default', badge: }},
fcmOptions: payload.imageUrl ? {imageUrl: payload.imageUrl} : undefined,
},
```
Notes:
- Rich images on iOS additionally require a **Notification Service Extension** target; skip it until
images matter, and the plain alert still shows.
- Notification taps: Android reads an `endpoint` intent extra in `onNewIntent`. On iOS use the
cross-platform `PushNotifications.addListener('pushNotificationActionPerformed', …)` and read
`notification.data.endpoint` — the `data` field above already carries it. Worth switching Android to
the same listener while we're here.
- `pushNotificationReceived` only fires in the foreground on iOS, and iOS suppresses the banner in
foreground by default — the existing `toast.success` fallback in `android-push.ts` covers that.
- Permission timing: `PushNotifications.requestPermissions()` triggers the one-shot iOS system prompt.
The current code fires it right after login. Consider asking in context instead — a denied iOS prompt
can only be reversed in Settings.
---
## 7. Build, sign, ship
Local:
```bash
yarn build-web-view
npx cap sync ios
npx cap open ios # then Product → Archive
```
Add `yarn build-sync-ios` mirroring `scripts/build_sync_android.sh`.
Versioning: `CFBundleShortVersionString` (user-visible, ≈ `versionName`) and `CFBundleVersion`
(build number, ≈ `versionCode`, must strictly increase per upload). Keep them in step with
`android/app/build.gradle` so a release is one version across both stores.
Signing and CI: the Android release path is
[`.github/workflows/cd-android.yml`](../.github/workflows/cd-android.yml) — bump `versionCode` on `main`,
Action builds a signed AAB and uploads to Play. The iOS analogue is a `macos-latest` job using **fastlane**
(`match` for certificate/profile management, `pilot` for TestFlight upload) with an **App Store Connect API
key** in GitHub Secrets. New secrets needed, alongside the existing `ANDROID_*` / `PLAY_SERVICE_ACCOUNT_JSON`:
```
APP_STORE_CONNECT_KEY_ID
APP_STORE_CONNECT_ISSUER_ID
APP_STORE_CONNECT_KEY_P8
MATCH_PASSWORD / MATCH_GIT_URL (or a manually managed .p12 + provisioning profile)
```
Do the first submission by hand from Xcode to shake out the metadata, then automate.
---
## 8. App Review risks specific to us
Ordered by how likely they are to cost us a rejection round:
1. **Guideline 4.2 — "minimum functionality" / repackaged website.** A pure WebView wrapper gets rejected.
Our defence is the same as on Play: local assets rather than a remote URL, plus genuine native
integration (push, native share, native Google/Apple sign-in, deep links). Do **not** ship the
remote-URL mode.
2. **Guideline 4.8 — Sign in with Apple.** See §5. Blocking.
3. **Guideline 5.1.1(v) — account deletion.** Apple requires in-app account deletion for any app with
account creation, reachable without contacting support. Verify the settings flow does this on-device.
4. **Guideline 1.2 / 1.1.6 — UGC on a dating-adjacent app.** Expect scrutiny: they will want a report
mechanism, a block mechanism, a published moderation policy, and a terms-of-service acceptance at
signup. Have the moderation story documented before submitting.
5. **Age rating.** A connections app rates 17+/18+; set it honestly or risk removal.
6. **Guideline 3.1.1 — in-app purchase.** If anything paid is ever added, iOS must route it through IAP
(30%/15%). Not an issue today; a reason not to add web-only checkout links to the iOS build later.
7. **Demo account.** Review needs working credentials in App Review notes, since the app is gated behind
login. Prepare a seeded account with a populated profile.
---
## 9. Suggested order of work
1. Rename `isAndroidApp``isNativeApp`, `android-push.ts``native-push.ts`; branch on
`Capacitor.getPlatform()`.
2. Move deep-link handling and notification-tap handling off `AndroidBridge`/intent extras onto
`@capacitor/app` + `pushNotificationActionPerformed` (works on both platforms).
3. Add the `apns` block to `sendPushToToken` — harmless on Android, prerequisite for iOS.
4. `npx cap add ios`, get it running in the Simulator against the static export.
5. Firebase iOS app + APNs key; verify push end-to-end on the **physical device** from §2 — the Simulator
has no APNs token, so this step cannot be faked.
6. Google Sign-In (iOS client ID) then Sign in with Apple.
7. Universal Links + `apple-app-site-association`.
8. Manual TestFlight build; internal testing.
9. fastlane + GitHub Action; first App Store submission.
---
## 10. Resources
- [Capacitor iOS docs](https://capacitorjs.com/docs/ios)
- [Firebase iOS setup](https://firebase.google.com/docs/ios/setup) ·
[APNs + FCM](https://firebase.google.com/docs/cloud-messaging/ios/certs)
- [App Store Review Guidelines](https://developer.apple.com/app-store/review/guidelines/)
- [Supporting Universal Links](https://developer.apple.com/documentation/xcode/supporting-associated-domains)
- [fastlane for iOS](https://docs.fastlane.tools/getting-started/ios/setup/)