|
| 1 | +# Implementing the iOS app |
| 2 | + |
| 3 | +Implementation notes for shipping Compass on iOS. Nothing here is built yet — this is the plan, written |
| 4 | +against what the Android app ([`android/README.md`](../android/README.md), |
| 5 | +[`android/CLAUDE.md`](../android/CLAUDE.md)) already does, so the work is framed as "what carries over" |
| 6 | +vs. "what has no iOS equivalent yet". |
| 7 | + |
| 8 | +The strategy is the same as Android: **Capacitor wrapper around the existing Next.js static export**. No |
| 9 | +React Native, no second UI codebase. `web/` stays the single source of the product. |
| 10 | + |
| 11 | +--- |
| 12 | + |
| 13 | +## 1. What already works, unchanged |
| 14 | + |
| 15 | +These are not iOS work items — they're already platform-agnostic and will light up as soon as an iOS |
| 16 | +target exists: |
| 17 | + |
| 18 | +| Piece | Where | |
| 19 | +| ------------------------------------------------ | --------------------------------------------------- | |
| 20 | +| The whole UI | `web/` — same static export as Android | |
| 21 | +| Static-export build (strips SSR/ISR/SSG) | `scripts/build_web_view.sh` | |
| 22 | +| Native-platform detection | `web/lib/util/webview.ts` (`isAndroidApp` — rename) | |
| 23 | +| Safe-area insets (notch / home indicator) | `web/styles/globals.css` `env(safe-area-inset-*)` | |
| 24 | +| Status-bar theming | `web/hooks/use-theme.ts` (`updateStatusBar`) | |
| 25 | +| Keyboard show/hide handling | `web/pages/_app.tsx` (`@capacitor/keyboard`) | |
| 26 | +| Native share sheet | `web/lib/util/share.ts` (`@capacitor/share`) | |
| 27 | +| Push registration + token save | `web/lib/service/android-push.ts` (rename) | |
| 28 | +| `save-subscription-mobile` endpoint + FCM tokens | `backend/api/src/save-subscription-mobile.ts` | |
| 29 | + |
| 30 | +The Capacitor plugins we already depend on (`@capacitor/app`, `keyboard`, `push-notifications`, `share`, |
| 31 | +`status-bar`, `@capgo/capacitor-social-login`) all support iOS. Nothing needs replacing. |
| 32 | + |
| 33 | +### Naming cleanup to do first |
| 34 | + |
| 35 | +`isAndroidApp()`, `AndroidPush`, `android-push.ts` are all misnomers the moment iOS exists — they already |
| 36 | +mean "native app". Rename to `isNativeApp()` / `NativePush` / `native-push.ts` before adding the platform, |
| 37 | +and use `Capacitor.getPlatform()` (`'ios' | 'android' | 'web'`) wherever behaviour genuinely diverges. |
| 38 | +`isNativeMobile()` in `web/lib/util/webview.ts` is already the right name and can stay. |
| 39 | + |
| 40 | +--- |
| 41 | + |
| 42 | +## 2. Prerequisites (hard blockers) |
| 43 | + |
| 44 | +- **A Mac.** Xcode is macOS-only, and there is no supported way to build or sign an iOS app without it. |
| 45 | + This includes CI: GitHub Actions needs a `macos-latest` runner (billed at 10× Linux minutes). |
| 46 | +- **Apple Developer Program membership** — $99/year. Not just for shipping: the Push Notifications and |
| 47 | + Sign in with Apple **entitlements are unavailable on a free account**, so the two features that make |
| 48 | + this more than a wrapped website can't even be built without it. |
| 49 | +- **A physical iPhone** — see below. The Simulator is not sufficient. |
| 50 | +- Xcode 16+, CocoaPods (`sudo gem install cocoapods`), Node 22+, Java is _not_ needed. |
| 51 | + |
| 52 | +### Test device |
| 53 | + |
| 54 | +Buy one. The Simulator can inject a local `.apns` payload into a running app, which validates our tap |
| 55 | +handler, but it **cannot obtain a real APNs device token**, so it can't exercise the part we actually need |
| 56 | +to trust: `PushNotifications.register()` → token → `save-subscription-mobile` → `sendPushToToken` → |
| 57 | +delivery. Everything in §6 is untestable without hardware. |
| 58 | + |
| 59 | +Hardware specs are close to irrelevant here — the app is a WKWebView, and the push plugin needs no Face ID, |
| 60 | +no Dynamic Island, no particular chip. Two things about the device do matter: |
| 61 | + |
| 62 | +- **It must have a notch or Dynamic Island.** Content sitting under the status bar is the single most |
| 63 | + common layout bug in a webview app, and our layout leans hard on `env(safe-area-inset-*)` |
| 64 | + (`web/styles/globals.css`, `bottom-nav-bar.tsx`, `filters.tsx`, `search.tsx`, `media-modal.tsx`). A |
| 65 | + device without one gives a bottom inset of `0px` and never surfaces those bugs. |
| 66 | +- **It must run a current iOS**, so the permission dialogs and APNs behaviour match what users see. |
| 67 | + |
| 68 | +**Recommendation: a refurbished iPhone 12 or 13, ~$150–250.** Notch, current iOS, cheap. That's the whole |
| 69 | +requirement. |
| 70 | + |
| 71 | +**Avoid the iPhone SE (2nd/3rd gen)** even though it's often the cheapest option — Home-button body, no |
| 72 | +notch, smaller screen. It would miss exactly the class of bug the device is being bought to catch, to save |
| 73 | +$50–100. False economy. |
| 74 | + |
| 75 | +Dynamic Island phones (14 Pro and later) have slightly larger top insets than notched ones, but since |
| 76 | +everything is driven by `env()` rather than hardcoded values, a notched device is a fine proxy. Check the |
| 77 | +top of the profile page and the filter sheet on whatever you get. |
| 78 | + |
| 79 | +--- |
| 80 | + |
| 81 | +## 3. Scaffolding the platform |
| 82 | + |
| 83 | +```bash |
| 84 | +yarn --cwd=web add -D @capacitor/ios |
| 85 | +npx cap add ios # creates ios/App/… at the repo root, alongside android/ |
| 86 | +yarn build-web-view |
| 87 | +npx cap sync ios |
| 88 | +npx cap open ios # opens ios/App/App.xcworkspace in Xcode |
| 89 | +``` |
| 90 | + |
| 91 | +`capacitor.config.ts` at the repo root is shared — `appId`, `appName`, `webDir: 'web/out'` and |
| 92 | +`includePlugins` all apply to both platforms as-is. Two things to add: |
| 93 | + |
| 94 | +```ts |
| 95 | +ios: { |
| 96 | + contentInset: 'always', // avoids WKWebView double-insetting under the notch |
| 97 | + scheme: 'Compass', // app is served from capacitor://; see §6 on cookies/CORS |
| 98 | +}, |
| 99 | +``` |
| 100 | + |
| 101 | +The dev-server override (`server: {url: 'http://10.0.2.2:3000', cleartext: true}`) is Android-specific: |
| 102 | +`10.0.2.2` is the Android emulator's alias for the host. The iOS Simulator shares the host's network, so it |
| 103 | +should use `localhost:3000`; a physical iPhone needs the LAN IP, same as |
| 104 | +`NEXT_PUBLIC_WEBVIEW_DEV_PHONE=1` already does. Branch on `process.env.CAP_PLATFORM` or just add an |
| 105 | +`ios.url` when we wire this up. Cleartext HTTP also needs an ATS exception in `Info.plist` |
| 106 | +(`NSAllowsLocalNetworking`) — **debug configuration only**, App Review rejects a blanket |
| 107 | +`NSAllowsArbitraryLoads`. |
| 108 | + |
| 109 | +Also register the repo-root `ios/` directory in `.gitignore` carefully: commit `ios/App/App.xcodeproj`, |
| 110 | +`Info.plist`, and the source, but ignore `ios/App/Pods/` and `ios/App/build/` (mirror what |
| 111 | +`android/.gitignore` does). |
| 112 | + |
| 113 | +--- |
| 114 | + |
| 115 | +## 4. Native code with no iOS equivalent yet |
| 116 | + |
| 117 | +`android/app/src/main/java/com/compassconnections/app/MainActivity.java` has grown four hand-written |
| 118 | +native features. Each needs a decision on iOS: |
| 119 | + |
| 120 | +### 4.1 Deep-link bridge (`handleAppLink`) |
| 121 | + |
| 122 | +Android stashes the launch `Intent` URL in `pendingDeepLink`, exposes it over a |
| 123 | +`@JavascriptInterface` (`window.AndroidBridge.getPendingDeepLink()`), and pushes later links in by calling |
| 124 | +`evaluateJavascript("handleAppLink(...)")` from `onNewIntent`. `web/pages/_app.tsx:198-209` consumes both |
| 125 | +paths. |
| 126 | + |
| 127 | +On iOS **don't reimplement the bridge** — `@capacitor/app` already gives you this cross-platform: |
| 128 | + |
| 129 | +```ts |
| 130 | +App.addListener('appUrlOpen', ({url}) => handleAppLink({endpoint: new URL(url).pathname})) |
| 131 | +const launch = await App.getLaunchUrl() // replaces getPendingDeepLink() |
| 132 | +``` |
| 133 | + |
| 134 | +Ideally migrate Android onto the same listener afterwards and delete the `AndroidBridge` deep-link half. |
| 135 | + |
| 136 | +Universal Links (the iOS equivalent of the `autoVerify` intent filter for `compassmeet.com`) need: |
| 137 | + |
| 138 | +- the **Associated Domains** capability with `applinks:compassmeet.com` and `applinks:www.compassmeet.com`, |
| 139 | +- an `apple-app-site-association` JSON file served from `https://compassmeet.com/.well-known/`, no |
| 140 | + redirect, `Content-Type: application/json`. Add it to `web/public/.well-known/` and confirm the Vercel |
| 141 | + config doesn't rewrite it. |
| 142 | + |
| 143 | +### 4.2 `downloadFile` (data export) |
| 144 | + |
| 145 | +`web/components/settings/general-settings.tsx:353` calls `window.AndroidBridge.downloadFile(...)` because |
| 146 | +Android's WebView won't honour a blob download. WKWebView on iOS 14+ _does_ handle |
| 147 | +`<a download>` / blob URLs and hands off to the share sheet. Simplest path: keep the `AndroidBridge` |
| 148 | +branch for Android, and on iOS fall through to `@capacitor/share` or `@capacitor/filesystem` |
| 149 | +(`Directory.Documents` + `Share.share({url})`). Don't write a Swift `WKScriptMessageHandler` unless that |
| 150 | +fails in testing. |
| 151 | + |
| 152 | +### 4.3 In-app update prompt |
| 153 | + |
| 154 | +`AppUpdateManagerFactory` / `AppUpdateType.IMMEDIATE` is Play-Store-only and **has no iOS counterpart** — |
| 155 | +Apple forbids apps from forcing their own updates. The iOS equivalent is either nothing (users update via |
| 156 | +the App Store) or a soft version check: query a `min-supported-version` value from the API on launch and |
| 157 | +render an in-app "please update" screen linking to the App Store. Ship without it first. |
| 158 | + |
| 159 | +### 4.4 Google Sign-In `onActivityResult` plumbing |
| 160 | + |
| 161 | +The `GoogleProvider.REQUEST_AUTHORIZE_GOOGLE_*` handling in `MainActivity` and the |
| 162 | +`ModifiedMainActivityForSocialLoginPlugin` interface are the Android-specific half of |
| 163 | +`@capgo/capacitor-social-login`. On iOS the plugin needs instead: |
| 164 | + |
| 165 | +- an **iOS OAuth client ID** in Google Cloud Console (we currently only have |
| 166 | + `WEB_GOOGLE_CLIENT_ID` in `common/src/constants.ts:48`; the commented-out `ANDROID_GOOGLE_CLIENT_ID` |
| 167 | + shows the shape), |
| 168 | +- the reversed client ID registered as a `CFBundleURLSchemes` entry in `Info.plist`, |
| 169 | +- `SocialLogin.initialize({google: {webClientId, iOSClientId}})` in |
| 170 | + `web/lib/firebase/users.ts:93`. |
| 171 | + |
| 172 | +The rest of `googleNativeLogin()` (exchange `idToken` → `signInWithCredential`) is unchanged. |
| 173 | + |
| 174 | +--- |
| 175 | + |
| 176 | +## 5. Sign in with Apple (required, new work) |
| 177 | + |
| 178 | +App Store Review Guideline **4.8** requires Sign in with Apple in any app that offers third-party social |
| 179 | +login — which we do. This is not optional and is a common first-submission rejection. |
| 180 | + |
| 181 | +Work involved: |
| 182 | + |
| 183 | +1. Enable the **Sign in with Apple** capability in Xcode and on the App ID. |
| 184 | +2. Enable the Apple provider in Firebase Console → Authentication, register the Services ID and key. |
| 185 | +3. Add an Apple button to the login UI, gated on `Capacitor.getPlatform() === 'ios'`. |
| 186 | +4. `@capgo/capacitor-social-login` supports `provider: 'apple'` — reuse the `googleNativeLogin` shape and |
| 187 | + `signInWithCredential(auth, OAuthProvider('apple.com').credential({idToken, rawNonce}))`. |
| 188 | +5. Apple's **private relay emails** (`…@privaterelay.appleid.com`) are real and deliverable but forwarded. |
| 189 | + Check that onboarding, `backend/email/` sends, and any email-uniqueness logic tolerate them, and that |
| 190 | + we handle the "name is only returned on the very first authorization" quirk — if we drop it, the user |
| 191 | + has no name and Apple will never send it again. |
| 192 | + |
| 193 | +--- |
| 194 | + |
| 195 | +## 6. Push notifications (APNs) |
| 196 | + |
| 197 | +`@capacitor/push-notifications` is already wired in `web/lib/service/android-push.ts` and works on iOS, but |
| 198 | +the transport underneath is different and the **backend payload is currently Android-only**. |
| 199 | + |
| 200 | +Setup: |
| 201 | + |
| 202 | +1. Push Notifications capability + `aps-environment` entitlement in Xcode. |
| 203 | +2. Create an APNs **auth key** (`.p8`, preferred over certs — doesn't expire) in the Apple Developer |
| 204 | + portal, upload it to Firebase Console → Project Settings → Cloud Messaging, with Team ID and Key ID. |
| 205 | + Then FCM tokens keep working and no backend token-storage change is needed |
| 206 | + (`push_subscriptions_mobile` stays as-is). |
| 207 | +3. Add the iOS app (bundle ID `com.compassconnections.app`) to the Firebase project and drop |
| 208 | + `GoogleService-Info.plist` into `ios/App/App/`. |
| 209 | + |
| 210 | +**Backend change required.** `sendPushToToken` in `backend/shared/src/mobile.ts:96` builds a `TokenMessage` |
| 211 | +with an `android.notification` block and a bare `data: {endpoint}`. Sent to an iOS token as-is, that is a |
| 212 | +_data-only_ push: it will not display anything and is delivered at low priority or not at all. Add: |
| 213 | + |
| 214 | +```ts |
| 215 | +apns: { |
| 216 | + payload: {aps: {alert: {title: payload.title, body: payload.body}, sound: 'default', badge: …}}, |
| 217 | + fcmOptions: payload.imageUrl ? {imageUrl: payload.imageUrl} : undefined, |
| 218 | +}, |
| 219 | +``` |
| 220 | + |
| 221 | +Notes: |
| 222 | + |
| 223 | +- Rich images on iOS additionally require a **Notification Service Extension** target; skip it until |
| 224 | + images matter, and the plain alert still shows. |
| 225 | +- Notification taps: Android reads an `endpoint` intent extra in `onNewIntent`. On iOS use the |
| 226 | + cross-platform `PushNotifications.addListener('pushNotificationActionPerformed', …)` and read |
| 227 | + `notification.data.endpoint` — the `data` field above already carries it. Worth switching Android to |
| 228 | + the same listener while we're here. |
| 229 | +- `pushNotificationReceived` only fires in the foreground on iOS, and iOS suppresses the banner in |
| 230 | + foreground by default — the existing `toast.success` fallback in `android-push.ts` covers that. |
| 231 | +- Permission timing: `PushNotifications.requestPermissions()` triggers the one-shot iOS system prompt. |
| 232 | + The current code fires it right after login. Consider asking in context instead — a denied iOS prompt |
| 233 | + can only be reversed in Settings. |
| 234 | + |
| 235 | +--- |
| 236 | + |
| 237 | +## 7. Build, sign, ship |
| 238 | + |
| 239 | +Local: |
| 240 | + |
| 241 | +```bash |
| 242 | +yarn build-web-view |
| 243 | +npx cap sync ios |
| 244 | +npx cap open ios # then Product → Archive |
| 245 | +``` |
| 246 | + |
| 247 | +Add `yarn build-sync-ios` mirroring `scripts/build_sync_android.sh`. |
| 248 | + |
| 249 | +Versioning: `CFBundleShortVersionString` (user-visible, ≈ `versionName`) and `CFBundleVersion` |
| 250 | +(build number, ≈ `versionCode`, must strictly increase per upload). Keep them in step with |
| 251 | +`android/app/build.gradle` so a release is one version across both stores. |
| 252 | + |
| 253 | +Signing and CI: the Android release path is |
| 254 | +[`.github/workflows/cd-android.yml`](../.github/workflows/cd-android.yml) — bump `versionCode` on `main`, |
| 255 | +Action builds a signed AAB and uploads to Play. The iOS analogue is a `macos-latest` job using **fastlane** |
| 256 | +(`match` for certificate/profile management, `pilot` for TestFlight upload) with an **App Store Connect API |
| 257 | +key** in GitHub Secrets. New secrets needed, alongside the existing `ANDROID_*` / `PLAY_SERVICE_ACCOUNT_JSON`: |
| 258 | + |
| 259 | +``` |
| 260 | +APP_STORE_CONNECT_KEY_ID |
| 261 | +APP_STORE_CONNECT_ISSUER_ID |
| 262 | +APP_STORE_CONNECT_KEY_P8 |
| 263 | +MATCH_PASSWORD / MATCH_GIT_URL (or a manually managed .p12 + provisioning profile) |
| 264 | +``` |
| 265 | + |
| 266 | +Do the first submission by hand from Xcode to shake out the metadata, then automate. |
| 267 | + |
| 268 | +--- |
| 269 | + |
| 270 | +## 8. App Review risks specific to us |
| 271 | + |
| 272 | +Ordered by how likely they are to cost us a rejection round: |
| 273 | + |
| 274 | +1. **Guideline 4.2 — "minimum functionality" / repackaged website.** A pure WebView wrapper gets rejected. |
| 275 | + Our defence is the same as on Play: local assets rather than a remote URL, plus genuine native |
| 276 | + integration (push, native share, native Google/Apple sign-in, deep links). Do **not** ship the |
| 277 | + remote-URL mode. |
| 278 | +2. **Guideline 4.8 — Sign in with Apple.** See §5. Blocking. |
| 279 | +3. **Guideline 5.1.1(v) — account deletion.** Apple requires in-app account deletion for any app with |
| 280 | + account creation, reachable without contacting support. Verify the settings flow does this on-device. |
| 281 | +4. **Guideline 1.2 / 1.1.6 — UGC on a dating-adjacent app.** Expect scrutiny: they will want a report |
| 282 | + mechanism, a block mechanism, a published moderation policy, and a terms-of-service acceptance at |
| 283 | + signup. Have the moderation story documented before submitting. |
| 284 | +5. **Age rating.** A connections app rates 17+/18+; set it honestly or risk removal. |
| 285 | +6. **Guideline 3.1.1 — in-app purchase.** If anything paid is ever added, iOS must route it through IAP |
| 286 | + (30%/15%). Not an issue today; a reason not to add web-only checkout links to the iOS build later. |
| 287 | +7. **Demo account.** Review needs working credentials in App Review notes, since the app is gated behind |
| 288 | + login. Prepare a seeded account with a populated profile. |
| 289 | + |
| 290 | +--- |
| 291 | + |
| 292 | +## 9. Suggested order of work |
| 293 | + |
| 294 | +1. Rename `isAndroidApp` → `isNativeApp`, `android-push.ts` → `native-push.ts`; branch on |
| 295 | + `Capacitor.getPlatform()`. |
| 296 | +2. Move deep-link handling and notification-tap handling off `AndroidBridge`/intent extras onto |
| 297 | + `@capacitor/app` + `pushNotificationActionPerformed` (works on both platforms). |
| 298 | +3. Add the `apns` block to `sendPushToToken` — harmless on Android, prerequisite for iOS. |
| 299 | +4. `npx cap add ios`, get it running in the Simulator against the static export. |
| 300 | +5. Firebase iOS app + APNs key; verify push end-to-end on the **physical device** from §2 — the Simulator |
| 301 | + has no APNs token, so this step cannot be faked. |
| 302 | +6. Google Sign-In (iOS client ID) then Sign in with Apple. |
| 303 | +7. Universal Links + `apple-app-site-association`. |
| 304 | +8. Manual TestFlight build; internal testing. |
| 305 | +9. fastlane + GitHub Action; first App Store submission. |
| 306 | + |
| 307 | +--- |
| 308 | + |
| 309 | +## 10. Resources |
| 310 | + |
| 311 | +- [Capacitor iOS docs](https://capacitorjs.com/docs/ios) |
| 312 | +- [Firebase iOS setup](https://firebase.google.com/docs/ios/setup) · |
| 313 | + [APNs + FCM](https://firebase.google.com/docs/cloud-messaging/ios/certs) |
| 314 | +- [App Store Review Guidelines](https://developer.apple.com/app-store/review/guidelines/) |
| 315 | +- [Supporting Universal Links](https://developer.apple.com/documentation/xcode/supporting-associated-domains) |
| 316 | +- [fastlane for iOS](https://docs.fastlane.tools/getting-started/ios/setup/) |
0 commit comments