diff --git a/.gitignore b/.gitignore index 41d1ffee..567c33cf 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,4 @@ UI-PLAN.md *_build_plan.md closer_partner_proof_reveal_privacy.md app/google-services.json.bk +app/GoogleService-Info.plist diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a9da5fbf..33df60b2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,7 +13,7 @@ android { compileSdk = 35 defaultConfig { - applicationId = "closer.app.package" + applicationId = "closer.app" minSdk = 26 targetSdk = 35 versionCode = 1 diff --git a/docs/Engineering_Reference_Manual.md b/docs/Engineering_Reference_Manual.md index 902e2efb..8275284d 100644 --- a/docs/Engineering_Reference_Manual.md +++ b/docs/Engineering_Reference_Manual.md @@ -91,8 +91,10 @@ app/src/main/java/app/closer/ ├── 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 @@ -105,14 +107,14 @@ app/src/main/java/app/closer/ ├── paywall/ # Subscription paywall ├── play/ # Play hub ├── questions/ # Daily question, packs, history - ├── settings/ # All settings screens (account, privacy, subscription, security…) - ├── thisorthat/ # This or That game + ├── settings/ # All settings screens (account, privacy, security, subscription, …) ├── theme/ # CloserTheme - ├── wheel/ # Spin the wheel - ├── components/ # Shared Compose components - └── auth/ # Auth screens + ├── thisorthat/ # This or That game + └── wheel/ # Spin the wheel ``` +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). ### iOS @@ -124,7 +126,7 @@ iphone/ ├── Package.swift # SPM dependency manifest ├── Closer.entitlements # Push, Keychain, App Groups ├── Info.plist # Bundle config, push entitlement, URL schemes -├── GoogleService-Info.plist # Firebase config (template; not committed) +├── GoogleService-Info.plist # Firebase config — gitignored, copy from your project └── Closer/ ├── CloserApp.swift # @main, AppState, AppDelegate, RevenueCat init ├── ContentView.swift # Root NavigationStack + TabView @@ -146,12 +148,14 @@ iphone/ ├── Wheel/ # Spin wheel ├── Dates/ # Date swipe, matches, builder, bucket list ├── Settings/ # Settings, paywall, subscription, help, data export - ├── Paywall/ # (placeholder; paywall lives in Settings for now) + ├── Paywall/ # Placeholder — paywall screen is rendered from Settings └── Resources/ # Illustrations, assets ``` The iOS `Crypto/` folder is **intentionally empty** today. The Swift port defers E2EE parity to a follow-up batch. The current iOS path creates `encryptionVersion = 0` couples and uses the plaintext answer path. See [iOS E2EE gap](#ios-e2ee-gap) for the precise scope and risk. +`Paywall/` is currently a placeholder; the actual paywall screen is rendered from `Settings/SettingsViews.swift`. A dedicated paywall view is a future cleanup. + ### Cloud Functions ```text @@ -542,6 +546,7 @@ Thread questions follow the same sealed flow but use a different path: categories: map /bucket_list/{itemId} title, category, addedByUserId, completedAt + /gentle_reminders/{YYYY-MM-DD} # one doc per calendar day per couple; daily lock /invites/{code} # server-only writes; 24h TTL code, inviterUserId, status: 'pending' | 'accepted' | 'expired' @@ -554,6 +559,10 @@ Thread questions follow the same sealed flow but use a different path: /date_ideas/{dateIdeaId} # read-only catalog title, description, category, imageUrl +/rate_limits/{uid}_gentle_reminder # server-only; rolling-hour transaction counter + windowStart: Timestamp + count: int + /entitlement_events/{eventId} # server-only; idempotency markers userId, type, source, processedAt ``` @@ -661,8 +670,8 @@ Every function module follows the same shape: ```text assignDailyQuestion 0 23 * * * America/Chicago scheduledOutcomesReminder * * * * * America/Chicago (per-minute, scans couples) -unlockDueMemoryCapsules (in gameRetention; check source for cron) -sendChallengeDayReminders (in gameRetention; check source for cron) +unlockDueMemoryCapsules every 1 hours (gameRetention.ts) +sendChallengeDayReminders (in gameRetention.ts; check source for cron) ``` `scheduledOutcomesReminder` currently scans all couples with no pagination. It will need to shard or paginate as the user base grows. @@ -730,7 +739,17 @@ Both clients use the Firebase Messaging SDK. Android uses FirebaseMessagingServi ### Daily question reminders -`sendDailyQuestionReminder` (callable) and `assignDailyQuestion` (scheduled) handle the two reminder paths. The scheduled function writes a new `daily_question/{date}` doc; the callable is used for manual re-triggering. +`sendDailyQuestionReminder` is a callable in `functions/src/notifications/reminders.ts`. **It is currently a placeholder** — its own source comment states "This is a placeholder scheduler. The actual daily scheduling will be handled by a Firestore trigger / Cloud Scheduler integration later." What it does today: + +- Validates that `data.userId === context.auth.uid` (no cross-user spam). +- Writes a record to `users/{userId}/notification_queue` with `type: 'daily_question'`, a default title, a default body, `read: false`, `sent: false`. +- Returns `{ queued: true, type: 'daily_question' }`. + +**It does not actually send an FCM push.** The notification is only delivered if a separate process consumes the queue and dispatches FCM. Today there is no such process, so `sent: false` records accumulate. The scheduled assignment function `assignDailyQuestion` is the authoritative daily-question pipeline; the reminder callable is a stub for the future FCM-based nudge. + +`sendPartnerAnsweredNotification` has the same placeholder shape — it writes a `notification_queue` record but does not dispatch FCM. Real-time partner-answered push is currently driven by the Firestore trigger `functions/src/questions/onAnswerWritten.ts`, which **does** send an FCM with both a data payload (routing to the reveal screen) and a notification block (system-tray display). + +When implementing real FCM delivery, consume the queue in this order: read unprocessed records from `users/{uid}/notification_queue` where `sent: false`, send FCM, mark `sent: true`. Use a `notification_queue_dispatched` counter to prevent double-send on retries. ### Partner-answered notification @@ -738,7 +757,16 @@ Both clients use the Firebase Messaging SDK. Android uses FirebaseMessagingServi ### Gentle reminders and challenges -`sendGentleReminderCallable` and `sendChallengeDayReminders` (in `gameRetention.ts`) handle ad-hoc nudges. `unlockDueMemoryCapsules` opens time capsules whose lock date has passed. +`sendGentleReminderCallable` is a real, rate-limited function for nudges. It enforces two limits: + +- **Per-user**: max 5 gentle reminders per rolling hour, gated by a server-side transaction on `rate_limits/{uid}_gentle_reminder`. The Android-side `NotificationRateLimiter` is a UX hint, not authoritative. +- **Per-couple**: one reminder per couple per calendar day (UTC). The lock is stored in `couples/{coupleId}/gentle_reminders/{date}` so it survives function restarts and is visible to both partners. + +The notification is both an FCM push (for the system tray) and an entry in `users/{partnerId}/notification_queue`. On rate-limit hit, the function returns `{ allowed: false, retryAfterMinutes }` rather than throwing. + +`sendChallengeDayReminders` and `unlockDueMemoryCapsules` (both in `gameRetention.ts`) handle scheduled nudges. The memory capsule unlock is a Pub/Sub schedule `every 1 hours` that opens capsules whose lock date has passed. The challenge reminder is scheduled daily. + +**Note on stale names**: `gameRetention.ts` is the file but its responsibilities are challenge reminders and memory capsule unlocks, not generic "game retention". The file name is a legacy name from an earlier product direction; renaming it is a low-priority cleanup. ### Per-user notification_queue @@ -808,15 +836,21 @@ When implementing iOS E2EE parity: ### Android - **Module**: `app/` -- **Package**: `app.closer` +- **Package**: `app.closer` (Java/Kotlin namespace) +- **Application ID**: `closer.app` (the on-device package identifier used by Google Play) - **compileSdk**: 35, **minSdk**: 26, **targetSdk**: 35 - **Java/Kotlin**: 17 - **Versioning**: `versionCode` is integer; `versionName` is a string. **Current state**: `versionCode = 1`, `versionName = "0.1.0"`. HISTORY.md describes versions up to `0.2.x`. Bump `versionName` in `app/build.gradle.kts` when cutting a release. +### Biometric recovery phrase reveal + +`app/src/main/java/app/closer/ui/settings/SecurityScreen.kt` gates the recovery phrase behind `BiometricPrompt` with `BIOMETRIC_STRONG or DEVICE_CREDENTIAL`. The setting `biometricLoginEnabled` is persisted via DataStore. On a device without biometric hardware the prompt falls back to device credential (PIN/pattern/password). The phrase is held in `SecurityViewModel`'s `_recoveryPhrase` `MutableStateFlow` and cleared on dialog dismiss — never written to logs or analytics. + ### Required build secrets - `RC_API_KEY` — RevenueCat public SDK key, sourced from `local.properties` or env. Release builds fail without it. -- `google-services.json` — Firebase config. The repo template does not include a real one; copy from your Firebase project. +- `google-services.json` — Firebase Android config. The repo template does not include a real one; copy from your Firebase project. +- `GoogleService-Info.plist` — Firebase iOS config. The repo gitignores this file; copy from your Firebase project into `iphone/Closer/GoogleService-Info.plist`. - `local.properties` — local-only, never committed. ### Gradle config