docs(manual): review pass 2 — iOS pairing actually broken, notification routes corrected, repo tree fixes, schedules updated, reveal-flow read-path gotcha
This commit is contained in:
parent
439ae7ce51
commit
c167211323
|
|
@ -80,47 +80,55 @@ Closer is a couples relationship app. The product goal is **private, mutual-reve
|
|||
```text
|
||||
app/src/main/java/app/closer/
|
||||
├── MainActivity.kt
|
||||
├── analytics/ # RetentionEvent + RetentionAnalytics (separate from core/analytics; used for product funnel)
|
||||
├── core/
|
||||
│ ├── analytics/ # Firebase Analytics + Crashlytics wrappers
|
||||
│ ├── billing/ # EntitlementChecker + FirestoreEntitlementChecker
|
||||
│ ├── crash/ # CrashReporter abstraction
|
||||
│ ├── feature/ # Feature flags
|
||||
│ ├── navigation/ # AppRoute constants, NavHost, ExternalLinks
|
||||
│ ├── notifications/ # FCM service, TokenRegistrar, quiet hours
|
||||
│ └── security/ # Auth rate limiter
|
||||
├── crypto/ # E2EE: Tink AEAD, BouncyCastle Argon2id, key stores
|
||||
│ ├── analytics/ # Firebase Analytics + Crashlytics wrappers
|
||||
│ ├── billing/ # EntitlementChecker + FirestoreEntitlementChecker
|
||||
│ ├── crash/ # CrashReporter abstraction
|
||||
│ ├── feature/ # (reserved for feature flags; currently empty — no feature-flag code in repo today)
|
||||
│ ├── navigation/ # AppRoute constants, NavHost, ExternalLinks
|
||||
│ └── notifications/ # AppMessagingService, NotificationHelper, NotificationPermissionHelper, QuietHours, TokenRegistrar
|
||||
├── crypto/ # E2EE: Tink AEAD, BouncyCastle Argon2id, key stores
|
||||
├── data/
|
||||
│ ├── local/ # Room DAOs, DataStore, EncryptedSharedPreferences
|
||||
│ ├── remote/ # Firestore data sources, Cloud Functions callable wrappers
|
||||
│ └── repository/ # Repository implementations
|
||||
│ ├── challenges/ # Connection Challenges data sources
|
||||
│ ├── local/ # Room DAOs, DataStore, EncryptedSharedPreferences (RecoveryPhraseStore, SecurePreferencesFactory, SettingsDataStore)
|
||||
│ ├── questions/ # Question pack data sources (QuestionDao + QuestionJsonParser)
|
||||
│ ├── remote/ # Firestore data sources, Cloud Functions callable wrappers
|
||||
│ ├── repository/ # Repository implementations
|
||||
│ └── security/ # PlayIntegrityChecker
|
||||
├── di/ # Hilt modules
|
||||
├── domain/
|
||||
│ ├── model/ # Plain data classes
|
||||
│ └── repository/ # Repository interfaces
|
||||
├── di/ # Hilt modules
|
||||
└── ui/ # Compose screens + ViewModels
|
||||
├── answers/ # Answer write/reveal/history
|
||||
├── auth/ # Auth screens
|
||||
├── brand/ # Logo, splash, illustrated empty states
|
||||
├── challenges/ # Connection Challenges
|
||||
├── components/ # Shared Compose components
|
||||
├── dates/ # Date builder, matches, bucket list
|
||||
├── desiresync/ # Preferences alignment exercise
|
||||
├── games/ # Game scaffolding
|
||||
├── home/ # Home dashboard + partner state
|
||||
├── howwell/ # How Well Do You Know Me game
|
||||
├── memorylane/ # Time capsules
|
||||
├── onboarding/ # Onboarding screens
|
||||
├── outcomes/ # 30/60/90 day check-ins
|
||||
├── pairing/ # Invite create/accept/confirm/recovery
|
||||
├── paywall/ # Subscription paywall
|
||||
├── play/ # Play hub
|
||||
├── questions/ # Daily question, packs, history
|
||||
├── settings/ # All settings screens (account, privacy, security, subscription, …)
|
||||
├── theme/ # CloserTheme
|
||||
├── thisorthat/ # This or That game
|
||||
└── wheel/ # Spin the wheel
|
||||
│ ├── model/ # Plain data classes
|
||||
│ ├── repository/ # Repository interfaces
|
||||
│ └── security/ # AuthRateLimiter, DeviceIntegrityChecker (interface for PlayIntegrityChecker)
|
||||
└── ui/ # Compose screens + ViewModels
|
||||
├── activity/ # Together / Activity screen
|
||||
├── answers/ # Answer write/reveal/history
|
||||
├── auth/ # Login, signup
|
||||
├── brand/ # Logo, splash, illustrated empty states
|
||||
├── challenges/ # Connection Challenges
|
||||
├── components/ # Shared Compose components (GamePromptBanner, EmptyState, BrandIllustration, etc.)
|
||||
├── dates/ # Date builder, matches, bucket list
|
||||
├── debug/ # Debug-only screens (QA tooling)
|
||||
├── desiresync/ # Preferences alignment exercise
|
||||
├── games/ # Game scaffolding
|
||||
├── home/ # Home dashboard + partner state
|
||||
├── howwell/ # How Well Do You Know Me game
|
||||
├── memorylane/ # Time capsules
|
||||
├── onboarding/ # Onboarding screens
|
||||
├── outcomes/ # 30/60/90 day check-ins
|
||||
├── pairing/ # Invite create/accept/confirm/recovery
|
||||
├── paywall/ # Subscription paywall
|
||||
├── play/ # Play hub
|
||||
├── questions/ # Daily question, packs, history
|
||||
├── settings/ # All settings screens (account, privacy, security, subscription, …)
|
||||
├── theme/ # CloserTheme
|
||||
├── thisorthat/ # This or That game
|
||||
└── wheel/ # Spin the wheel
|
||||
```
|
||||
|
||||
**Note on the manual's older description**: a `core/security/` package and `data/local/QuestionJsonParser.kt` were documented in earlier revisions of this manual but don't exist in the current source. The auth rate limiter is in `domain/security/AuthRateLimiter.kt`, and the question JSON parser is at `data/questions/QuestionJsonParser.kt`.
|
||||
|
||||
The Android settings package contains: `SettingsScreen`, `SettingsViewModel`, `SettingsVisuals`, `AccountScreen`, `EditProfileScreen` + `EditProfileViewModel`, `AppearanceScreen`, `DeleteAccountScreen`, `NotificationSettingsScreen`, `PrivacyScreen`, `RelationshipSettingsScreen`, `SecurityScreen`, `SubscriptionScreen`. The `SecurityScreen` is biometric-gated for the recovery phrase reveal.
|
||||
|
||||
The `app/src/main/res/drawable-nodpi/` folder holds brand illustrations (onboarding, invite, paywall, subscription, history).
|
||||
|
|
@ -186,12 +194,15 @@ functions/src/
|
|||
├── games/
|
||||
│ └── onGameSessionUpdate.ts # Trigger: game state changes → notify partner
|
||||
├── notifications/
|
||||
│ ├── reminders.ts # sendDailyQuestionReminder, sendPartnerAnsweredNotification
|
||||
│ ├── reminders.ts # sendDailyQuestionReminder + sendPartnerAnsweredNotification (PLACEHOLDER callable; not deployed)
|
||||
│ ├── dailyQuestionReminder.ts # sendDailyQuestionProactiveReminder (4 PM CT; deployed, idempotent)
|
||||
│ ├── reengagement.ts # sendReengagementReminder (12 PM CT; couples 3–10 days quiet)
|
||||
│ ├── sendGentleReminderCallable.ts # Manual gentle reminder
|
||||
│ └── gameRetention.ts # Challenge day reminders, capsule unlocks
|
||||
│ └── gameRetention.ts # sendChallengeDayReminders + unlockDueMemoryCapsules
|
||||
├── questions/
|
||||
│ ├── assignDailyQuestion.ts # Pub/Sub schedule + manual callable
|
||||
│ ├── onAnswerWritten.ts # Trigger: notify partner on answer
|
||||
│ ├── onAnswerRevealed.ts # Trigger: notify partner when answers are opened
|
||||
│ └── onMessageWritten.ts # Trigger: thread messages
|
||||
├── security/
|
||||
│ └── checkDeviceIntegrity.ts # Play Integrity verdict verification
|
||||
|
|
@ -201,6 +212,11 @@ functions/src/
|
|||
|
||||
There is no `auth/` module. Authentication is handled entirely by the Firebase Auth client SDK; the Admin SDK is used in the `users/onUserDelete.ts` trigger and in `couples/acceptInviteCallable.ts` to read user docs.
|
||||
|
||||
**Two reminders exist for the daily question** and they are NOT the same:
|
||||
|
||||
- `sendDailyQuestionReminder` in `reminders.ts` is an `onCall` **placeholder** — it writes a `notification_queue` record but does NOT send FCM. Not deployed in the live reminder flow.
|
||||
- `sendDailyQuestionProactiveReminder` in `dailyQuestionReminder.ts` is the **actual deployed scheduler** — fires at `0 16 * * *` America/Chicago (4 PM, 2 hours before the daily question expires at 6 PM). Queries expiring `daily_question` docs via collection group, skips couples where anyone has answered OR where a reminder was already sent today, sends FCM + writes `notification_queue`, records `automated_daily_reminder/{docId}` for idempotency.
|
||||
|
||||
### Shared configuration
|
||||
|
||||
```text
|
||||
|
|
@ -319,13 +335,13 @@ iOS does not generate or store a recovery phrase in the current build. iOS coupl
|
|||
|
||||
| Version | Name | Meaning |
|
||||
| --- | --- | --- |
|
||||
| 0 | `PLAINTEXT` | No couple key; answers may be plaintext. **iOS only** — every iOS-originated couple is created at v0 because the iOS port does not implement E2EE today (see [iOS E2EE gap](#ios-e2ee-gap)). No Android couple is ever created at v0. |
|
||||
| 1 | `MIGRATING` | Reserved for a hypothetical in-flight migration. **No couple exists at v1 in production today.** The constant is documented in `EncryptionVersion.kt` for forward-compatibility; do not write v1 from new code without a concrete migration plan. |
|
||||
| 2 | `STRICT` | All answer-bearing paths require encryption. **Default for every Android-originated couple**; the only state `acceptInviteCallable.ts` writes when E2EE fields are present. |
|
||||
| 0 | `PLAINTEXT` | **Conceptual only — not creatable today.** The constant is referenced in iOS TODO comments and the Firestore rules helper `coupleEncryptionEnabled`, but `acceptInviteCallable` hardcodes `encryptionVersion = 2` and throws if E2EE fields are missing. No v0 couple exists in the live system. See [iOS E2EE gap](#ios-e2ee-gap-pairing-is-broken-from-ios-today). |
|
||||
| 1 | `MIGRATING` | Reserved for a hypothetical in-flight migration. **No couple exists at v1 in production today.** Documented in `EncryptionVersion.kt` for forward-compatibility; do not write v1 from new code without a concrete migration plan. |
|
||||
| 2 | `STRICT` | All answer-bearing paths require encryption. **The only version any couple has ever been created at.** Required by both `acceptInviteCallable` and the Firestore rules (which reject `request.resource.data.encryptionVersion != 2`). |
|
||||
|
||||
**What the code actually does**: the Android `EncryptionVersion` object defines only `STRICT = 2` and `NEW_COUPLE_DEFAULT = STRICT`. v0 and v1 are conceptual states that exist on paper for iOS compatibility and migration planning; nothing in the Android source path writes them. Do not change this without auditing iOS cross-platform pairing (a mixed v0/v2 couple is the only combination that the rules tolerate today, and only because Android handles plaintext gracefully when the partner is iOS).
|
||||
**What the code actually does**: the Android `EncryptionVersion` object defines only `STRICT = 2` and `NEW_COUPLE_DEFAULT = STRICT`. `acceptInviteCallable` sets `const encryptionVersion = 2` unconditionally and `throw`s if any of `wrappedCoupleKey`/`kdfSalt`/`kdfParams` is null. v0 and v1 are conceptual states referenced by iOS TODOs and the rules helper `coupleEncryptionEnabled(>= 1)`; nothing in the production path writes them.
|
||||
|
||||
The Cloud Function `acceptInviteCallable.ts` derives `encryptionVersion` from whether E2EE fields are present: if `wrappedCoupleKey`, `kdfSalt`, and `kdfParams` are all non-null, the couple is created at v2; otherwise v0. This keeps the iOS-and-Android-different-defaults case from breaking.
|
||||
**If you need to support a non-v2 couple** (e.g. iOS plaintext fallback, legacy migration): the change touches three places at minimum — `EncryptionVersion.kt` adds the constant, `acceptInviteCallable` derives it from caller data, and `firestore.rules` `match /couples/{coupleId}` `allow create` block removes the `encryptionVersion == 2` gate and adds the corresponding E2EE-field-optional helper. Plan all three together; do not ship one without the others.
|
||||
|
||||
### Couple key wrapping with Argon2id
|
||||
|
||||
|
|
@ -497,6 +513,8 @@ The reveal path differs by schema version:
|
|||
|
||||
**Adding a new answer-bearing path**: decide upfront whether schemaVersion 2 (couple-key, simple, current default) or schemaVersion 3 (sealed, partner-proof, more code) is appropriate. New schemas require new `isXxxAnswerCreate` helpers in `firestore.rules` AND new write paths in the data source.
|
||||
|
||||
**Read-path gotcha**: `FirestoreAnswerDataSource.toLocalAnswer()` (the `DocumentSnapshot.toLocalAnswer()` private helper) hardcodes `schemaVersion = 3` and reads `encryptedPayload` from the doc itself. For the v2 path this is wrong in isolation — v2 daily answers don't have `encryptedPayload` on the doc; that field lives in the `secure/payload` subdoc. In practice the v2 reveal still works because `computeSealedPhase()` in `AnswerRevealViewModel` reads the *user's own* answer's `schemaVersion` from the local SharedPreferences repository (which preserves the actual value), not the partner's. The partner's hardcoded `schemaVersion = 3` is dead data on this read path. The actual partner content is fetched via `decryptCoupleKeyAnswerFor()`, which reads the `secure/payload` subdoc directly. If you refactor the read path, preserve this invariant: the user's own answer schemaVersion must drive the reveal branch.
|
||||
|
||||
### Thread questions
|
||||
|
||||
Thread questions follow the same sealed flow but use a different path:
|
||||
|
|
@ -530,11 +548,12 @@ Thread questions follow the same sealed flow but use a different path:
|
|||
/outcomes/{dayKey} # day_0, day_30, day_60, day_90; server-only
|
||||
submittedAt: Timestamp
|
||||
answers: map
|
||||
/notification_queue/{id} # server-only; partner-pending notifications
|
||||
type: string
|
||||
payload: map
|
||||
/notification_queue/{id} # server-only; in-app activity feed
|
||||
type: string # e.g. 'invite_created', 'partner_joined', or whatever the caller writes
|
||||
read: bool # only field the rules allow the client to update
|
||||
createdAt: Timestamp
|
||||
delivered: bool
|
||||
# Plus arbitrary caller-supplied fields (the writer spreads them onto the doc).
|
||||
# See functions/src/games/onGameSessionUpdate.ts and functions/src/couples/createInviteCallable.ts for examples.
|
||||
/invite_attempts/{id} # rate-limit; Firestore TTL
|
||||
code: string
|
||||
attemptedAt: Timestamp
|
||||
|
|
@ -698,13 +717,15 @@ Every function module follows the same shape:
|
|||
### Schedule
|
||||
|
||||
```text
|
||||
assignDailyQuestion 0 23 * * * America/Chicago
|
||||
scheduledOutcomesReminder * * * * * America/Chicago (per-minute, scans couples)
|
||||
unlockDueMemoryCapsules every 1 hours (gameRetention.ts)
|
||||
sendChallengeDayReminders (in gameRetention.ts; check source for cron)
|
||||
assignDailyQuestion 0 23 * * * America/Chicago (11 PM UTC; 6 PM Chicago during CDT, 5 PM during CST — see Date math DST bug)
|
||||
scheduledOutcomesReminder every 24 hours America/Chicago
|
||||
sendDailyQuestionProactiveReminder 0 16 * * * America/Chicago (4 PM; 2h before expiry)
|
||||
sendReengagementReminder 0 12 * * * America/Chicago (noon; targets couples 3–10 days quiet)
|
||||
unlockDueMemoryCapsules every 1 hours (gameRetention.ts)
|
||||
sendChallengeDayReminders every 24 hours (gameRetention.ts)
|
||||
```
|
||||
|
||||
`scheduledOutcomesReminder` currently scans all couples with no pagination. It will need to shard or paginate as the user base grows.
|
||||
`scheduledOutcomesReminder` currently scans all couples with no pagination. It will need to shard or paginate as the user base grows. `sendDailyQuestionProactiveReminder` is the only deployed reminder for unanswered daily questions — the placeholder `sendDailyQuestionReminder` callable in `reminders.ts` is not in the live reminder path.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -848,30 +869,30 @@ Key Android files: `app/src/main/java/app/closer/notifications/GamePromptControl
|
|||
|
||||
### Notification deep-link routing
|
||||
|
||||
`PartnerNotificationManager` (Android) parses the FCM data payload and routes to the right screen. The full routing table (from `PartnerNotificationType.fromRemoteType`):
|
||||
`PartnerNotificationManager` (Android) parses the FCM data payload and routes to the right screen via `PartnerNotificationType.routeFor(payload, coupleId)`. The full routing table (verified against `PartnerNotificationManager.kt`):
|
||||
|
||||
| Payload key | Notification type | Routes to |
|
||||
| --- | --- | --- |
|
||||
| `partner_answered` | `PARTNER_ANSWERED` | `ANSWER_REVEAL` for that date |
|
||||
| `partner_opened_answer` | `PARTNER_OPENED_ANSWER` | `ANSWER_REVEAL` |
|
||||
| `reveal_ready` | `REVEAL_READY` | `ANSWER_REVEAL` (both-answered couple-key unlock) |
|
||||
| `partner_started_game` | `PARTNER_STARTED_GAME` | Game session screen for that game |
|
||||
| `partner_completed_part` | `PARTNER_COMPLETED_PART` | The user's part screen for that session |
|
||||
| `partner_finished_game`, `game_results_ready` | `GAME_RESULTS_READY` | Results screen for that session |
|
||||
| `challenge_day_ready`, `challenge_waiting` | `CHALLENGE_WAITING` | Challenge screen |
|
||||
| `memory_capsule_unlocked` | `CAPSULE_UNLOCKED` | Memory Lane |
|
||||
| `daily_question`, `daily_question_reminder` | `DAILY_QUESTION_REMINDER` | Today screen |
|
||||
| `chat_message` | `CHAT_MESSAGE` | Thread screen |
|
||||
| `outcome_reminder` | `OUTCOME_REMINDER` | Outcomes check-in |
|
||||
| `gentle_reminder` | `GENTLE_REMINDER` | Home |
|
||||
| `partner_joined` | `PARTNER_JOINED` | Pairing confirm |
|
||||
| `date_match` | `DATE_MATCH` | Date Matches screen |
|
||||
| `reengagement` | `REENGAGEMENT` | Home |
|
||||
| `partner_left`, `partner_deleted_account` | `PARTNER_UNPAIRED` | Settings / pair again flow |
|
||||
| `partner_answered` | `PARTNER_ANSWERED` | `DAILY_QUESTION` (the partner's `answers/{userId}` metadata doc exists; reveal reads it on next visit) |
|
||||
| `partner_opened_answer` | `PARTNER_OPENED_ANSWER` | `answerReveal(questionId)` if `questionId` in payload, else `DAILY_QUESTION` |
|
||||
| `reveal_ready` | `REVEAL_READY` | `answerReveal(questionId)` if `questionId` in payload, else `ANSWER_HISTORY` |
|
||||
| `partner_started_game` | `PARTNER_STARTED_GAME` | `gameRouteForType(payload.gameType)` (e.g. `WHEEL_SESSION`, `THIS_OR_THAT_SESSION`) — deep link into the waiting game so the screen auto-joins the active session. Falls back to `PLAY` if `gameType` is missing. E-003. |
|
||||
| `partner_completed_part` | `PARTNER_COMPLETED_PART` | `gameRouteForType(payload.gameType)` — same as started_game |
|
||||
| `partner_finished_game`, `game_results_ready` | `GAME_RESULTS_READY` | `gameResultsRouteFor(gameType, gameSessionId)` — results/replay route (the plain game route would show "start a new game" because `getActiveSession` only returns active sessions). Falls back to `PLAY`. E-003. |
|
||||
| `challenge_day_ready`, `challenge_waiting` | `CHALLENGE_WAITING` | `CONNECTION_CHALLENGES` |
|
||||
| `memory_capsule_unlocked` | `CAPSULE_UNLOCKED` | `MEMORY_LANE` |
|
||||
| `daily_question`, `daily_question_reminder` | `DAILY_QUESTION_REMINDER` | `DAILY_QUESTION` |
|
||||
| `chat_message` | `CHAT_MESSAGE` | `conversation(coupleId, conversationId)` if both ids present, else `MESSAGES` |
|
||||
| `outcome_reminder` | `OUTCOME_REMINDER` | `SETTINGS` (not Outcomes — Outcomes screen itself has no dedicated route yet) |
|
||||
| `gentle_reminder` | `GENTLE_REMINDER` | `DAILY_QUESTION` |
|
||||
| `partner_joined` | `PARTNER_JOINED` | `pairingSuccess(coupleId)` if `coupleId` known, else `HOME` |
|
||||
| `date_match` | `DATE_MATCH` | `DATE_MATCHES` |
|
||||
| `reengagement` | `REENGAGEMENT` | `DAILY_QUESTION` |
|
||||
| `partner_left`, `partner_deleted_account` | `PARTNER_UNPAIRED` | `HOME` (Home surfaces the "Invite partner" CTA for the now-solo user; E-002) |
|
||||
|
||||
When the app is foreground, the banner takes precedence over deep-link (the user already saw it). When background, `MainActivity` `singleTop` launch mode + a server-first read in `PartnerNotificationManager` ensure the deep-link lands in the active game, not a stale state. **E-GAME-001** was a previous bug here (deep-link re-entered a finished session) — fixed by server-first read.
|
||||
|
||||
**Adding a new notification type**: add the enum value in `PartnerNotificationType` with title/body/channelId/rateType, then add a `when` branch in `fromRemoteType` mapping the server's payload key. Server-side: emit the matching payload key from the Cloud Function. Both sides must be updated together; mismatches silently drop the notification.
|
||||
**Adding a new notification type**: add the enum value in `PartnerNotificationType` with title/body/channelId/rateType, add a branch in `routeFor(...)` mapping to the appropriate `AppRoute`, and add a `when` branch in `fromRemoteType(...)` mapping the server's payload key. Server-side: emit the matching payload key from the Cloud Function. **All four edits must ship together**; mismatches silently drop the notification or route it to a wrong screen.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -905,21 +926,27 @@ xcodebuild -project iphone/Closer.xcodeproj \
|
|||
build
|
||||
```
|
||||
|
||||
### iOS E2EE gap
|
||||
### iOS E2EE gap (pairing is broken from iOS today)
|
||||
|
||||
The iOS port does not implement E2EE today. Concrete consequences:
|
||||
The iOS port does not implement E2EE. **More importantly, this means iOS cannot complete pairing today:**
|
||||
|
||||
- iOS couples are created with `encryptionVersion = 0`. Their `couples` doc has no `wrappedCoupleKey` / `kdfSalt` / `kdfParams`.
|
||||
- iOS answer writes use the plaintext path. Firestore rules allow plaintext only when `encryptionVersion < 1`.
|
||||
- iOS does not generate or store a recovery phrase.
|
||||
- iOS does not have a CryptoKit implementation of Tink keyset serialization, Argon2id KDF, or sealed-answer ECIES.
|
||||
- iOS premium state is RevenueCat-only, not server-verified.
|
||||
- `createInviteCallable` (iOS caller passes `[:]` as data) writes an invite with **no** `wrappedCoupleKey` / `kdfSalt` / `kdfParams` / `recoveryPhrase` fields.
|
||||
- `acceptInviteCallable` then **throws** `failed-precondition: "Invite is missing encryption material"` when an iOS user tries to accept any invite (Android or iOS) because those fields are required (`if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null) throw ...`).
|
||||
- The Firestore rules also reject any client-side attempt to write a couple with `encryptionVersion != 2` or without all four E2EE fields. So there is no fallback client-write path either.
|
||||
|
||||
A user who pairs an Android device with an iOS device today creates a mixed-couple (`encryptionVersion = 2` from Android, but the iOS partner's flow is still plaintext). The Android-side rules tolerate this, but the Android user is the only one whose answers are actually encrypted — the iOS user's answers are plaintext. **Do not ship this combination to users** without:
|
||||
Concrete consequences if someone tries to pair from iOS:
|
||||
|
||||
1. A CryptoKit E2EE implementation that produces byte-compatible ciphertexts with the Android Tink paths.
|
||||
2. An Argon2id implementation that produces identical bytes for the recovery phrase KDF (e.g. `SwiftArgon2` with the exact same parameters: `m=46080 KiB, t=3, p=1, salt=16 bytes`).
|
||||
3. Server-side gating that refuses to pair cross-platform couples until iOS has parity, OR an explicit user-facing "iOS answers are not yet encrypted" notice.
|
||||
- An iOS user trying to create an invite gets a code that nobody can accept (server rejects on accept).
|
||||
- An iOS user trying to accept an Android invite gets `failed-precondition` from `acceptInviteCallable`.
|
||||
- There is no v0 / PLAINTEXT couple creation path in the codebase. The `EncryptionVersion.kt` constants file defines only `STRICT = 2`. `acceptInviteCallable` hardcodes `encryptionVersion = 2`. Rules require `encryptionVersion == 2`.
|
||||
|
||||
**State of iOS today**: the iOS app builds, signs in, and renders UI, but pairing is non-functional. Do not ship iOS to users until one of these is true:
|
||||
|
||||
1. **E2EE parity ships on iOS** — CryptoKit keyset + Argon2id phrase cipher that produce byte-compatible ciphertexts with the Android Tink paths. See [iOS CryptoKit guidance](#ios-cryptokit-guidance-future). Then `createInviteCallable` (iOS) starts sending the four E2EE fields and pairing works.
|
||||
2. **Server-side fallback is implemented** — `acceptInviteCallable` is changed to accept invites with null E2EE fields when the caller is iOS (platform detected from the user's `platform` field). This would create v0 couples. Rules must be updated to allow v0 creation; the encryption-versions table then becomes the live state space.
|
||||
3. **Mixed-couple policy is communicated to users** — explicitly tell iOS users their answers are plaintext and the couple's Android answers are encrypted but not cross-decryptable. This requires shipping #2 and a UI flow that surfaces the gap.
|
||||
|
||||
**As of the current manual revision (2026-06), none of (1)/(2)/(3) has shipped.** Pairing from iOS fails.
|
||||
|
||||
### iOS CryptoKit guidance (future)
|
||||
|
||||
|
|
@ -1045,7 +1072,7 @@ SCRIPTS.md
|
|||
### Logging
|
||||
|
||||
- Cloud Functions prefix every log line with the function name: `[acceptInviteCallable] ...`.
|
||||
- Android production builds must not log secrets, recovery phrases, keyset bytes, or invite codes. Wrap `android.util.Log` calls in `BuildConfig.DEBUG` guards (see `app/src/main/java/app/closer/data/local/QuestionJsonParser.kt` for an example).
|
||||
- Android production builds must not log secrets, recovery phrases, keyset bytes, or invite codes. Wrap `android.util.Log` calls in `BuildConfig.DEBUG` guards (see `app/src/main/java/app/closer/data/questions/QuestionJsonParser.kt` for an example).
|
||||
- Crashlytics is the production observability path. Do not log to both Crashlytics and console in production.
|
||||
|
||||
### Error handling
|
||||
|
|
|
|||
Loading…
Reference in New Issue