Files
Compass/docs/ios.md

16 KiB
Raw Blame History

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/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-mobilesendPushToToken → 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

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:

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:

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:

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 idTokensignInWithCredential) 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:

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:

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 — 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 isAndroidAppisNativeApp, android-push.tsnative-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