mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 09:48:47 -04:00
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:
@@ -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).
|
||||
|
||||
@@ -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
316
docs/ios.md
Normal 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, ~$150–250.** 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
|
||||
$50–100. 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/)
|
||||
Reference in New Issue
Block a user