docs(manual): fix placeholder notification function descriptions, add new collections, biometric flow, iOS gitignore note

This commit is contained in:
null 2026-06-21 18:47:18 -05:00
parent 578851964c
commit 54fdaf831f
3 changed files with 49 additions and 14 deletions

1
.gitignore vendored
View File

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

View File

@ -13,7 +13,7 @@ android {
compileSdk = 35
defaultConfig {
applicationId = "closer.app.package"
applicationId = "closer.app"
minSdk = 26
targetSdk = 35
versionCode = 1

View File

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