diff --git a/docs/Engineering_Reference_Manual.md b/docs/Engineering_Reference_Manual.md index 203d7c34..5d34d770 100644 --- a/docs/Engineering_Reference_Manual.md +++ b/docs/Engineering_Reference_Manual.md @@ -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