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:
null 2026-06-27 15:14:09 -05:00
parent 439ae7ce51
commit c167211323
1 changed files with 109 additions and 82 deletions

View File

@ -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 310 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 310 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