diff --git a/ClaudeReport.md b/ClaudeReport.md index ccab9b7a..d219f3f6 100644 --- a/ClaudeReport.md +++ b/ClaudeReport.md @@ -1,128 +1,71 @@ -# Claude QA Report β€” Games Audit +# Claude QA Report β€” Games & Notifications -**Date:** 2026-06-23 -**Method:** Per-game code review (state machine, E2EE, navigation, session lock) + build verification. Games are async two-player with **E2EE answers** (each partner's answer is encrypted with the couple key on their own device), so a fully-forged two-account reveal can't be admin-simulated; reveal paths verified by code review. Live solo smoke testing on emulator-5554 where reachable. +**Updated:** 2026-06-24 +**Devices:** emulator-5554 (Device A = QATester) + emulator-5556 (Device B = Sam), paired for real (coupleId `xNd1H2UGUDNqvyrDGgfu`). +**Focus this pass:** every game works end-to-end **and notifications fire correctly on game start + finish.** -**Severity legend:** -- πŸ”΄ **CRITICAL** β€” breaks the game / data loss / crash / wrong reveal. -- 🟠 **HIGH** β€” broken in a common path but recoverable, or gated on a deploy. -- 🟑 **MEDIUM** β€” wrong behavior in an edge case / confusing UX. -- 🟒 **LOW** β€” cosmetic. - -Status key: πŸ”Ž found Β· πŸ›  fixing Β· βœ… fixed & builds. +**Severity:** πŸ”΄ critical Β· 🟠 high Β· 🟑 medium Β· 🟒 low +**Status:** πŸ”Ž found Β· πŸ›  fixing Β· βœ… fixed & builds Β· βœ…βœ… verified live Β· ⚠️ needs deploy --- -## Findings & status +## OPEN β€” current error log -### 1. Spin the Wheel β€” βœ… no bugs found -Correct WAITING/REVEAL gating, releases the one-game lock via `markUserComplete` on reveal, has an `abandon` path, and a **re-entry guard** (`load()` jumps to the reveal if the user already answered β€” doesn't re-ask). Reference implementation for the other games. +### N1. πŸ”΄ FCM token was NEVER registered β†’ no push notifications worked at all β€” βœ…βœ… FIXED & VERIFIED +`TokenRegistrar.register()` (which fetches `messaging.token` and stores it) was **never called anywhere**. The only other path, `AppMessagingService.onNewToken`, bails with `currentUserId ?: return` β€” and FCM generates the token at **install, before sign-in**, so `onNewToken` ran with no uid and stored nothing; it never fires again afterwards. Result: **`users/{uid}.fcmToken` was empty for every account**, so no game/message/daily push could ever be delivered. +**Fix:** `MainActivity` now observes `authState` and calls `tokenRegistrar.register()` whenever a user is authenticated. ([MainActivity.kt](app/src/main/java/app/closer/MainActivity.kt)) +**Verified live:** after the fix both A and B have a stored `fcmToken`; a direct push to A rendered the heads-up "Your partner started a game β€” Tap to join them." -### 2. This or That β€” 🟑 MEDIUM β†’ βœ… fixed -**Bug:** Re-opening the game while waiting for the partner re-entered `joinSession`, which set `PLAYING` (the actual question screen) before `observeReveal` could flip to `WAITING` β€” a flicker of the already-answered question plus a small window to re-submit. -**Fix:** `joinSession` now pre-checks `getAnswers().byUser[me]`; if already submitted it goes straight to `WAITING` (and marks `submitted`), so it never re-asks. ([ThisOrThatScreen.kt](app/src/main/java/app/closer/ui/thisorthat/ThisOrThatScreen.kt)) +### N2. πŸ”΄ POST_NOTIFICATIONS permission was never requested β†’ notifications can't display on Android 13+ β€” βœ… FIXED +`NotificationPermissionHelper` existed but had **no caller**. On API 33+ notifications are silently dropped without the runtime grant. +**Fix:** `MainActivity` requests `POST_NOTIFICATIONS` on launch via an Activity Result launcher. ([MainActivity.kt](app/src/main/java/app/closer/MainActivity.kt)) -### 3. How Well Do You Know Me β€” 🟑 MEDIUM β†’ βœ… fixed -**Bug:** Same re-entry pattern β€” `joinSession` set `INTRO` then relied on `observeReveal` to flip; an already-answered user could tap into the answer flow during the window. -**Fix:** Pre-check existing answers β†’ straight to `WAITING`. ([HowWellScreen.kt](app/src/main/java/app/closer/ui/howwell/HowWellScreen.kt)) +### N3. 🟠 Game-START notification named the WRONG person β€” βœ… FIXED ⚠️ needs functions deploy +`onGameSessionUpdate` passed the **recipient's** name into the body, so the partner saw *" has started a game"* (live: B/Sam received "Sam has started a game"). It should name the **starter**. +**Fix:** use `startedByUserId` β†’ starter's name + avatar for both title and body. ([onGameSessionUpdate.ts](functions/src/games/onGameSessionUpdate.ts)) -### 4. Desire Sync β€” 🟑 MEDIUM β†’ βœ… fixed -**Bug:** Same re-entry pattern (`INTRO` before the observer flips). -**Fix:** Pre-check existing answers β†’ straight to `WAITING`. ([DesireSyncScreen.kt](app/src/main/java/app/closer/ui/desiresync/DesireSyncScreen.kt)) -**Note (by design, not a bug):** the reveal shows only mutually-positive desires; non-matches stay private. +### N4. 🟠 Game-FINISH notification only reached one partner β€” βœ… FIXED ⚠️ needs functions deploy +Completion branch gated the "notify both" path on `currentData.partnerCompletedAt`, **a field the client never writes** (the client tracks `completedByUsers`). So when both finished, the partner who'd been waiting often got nothing (or a "tap to continue playing" that no longer applied). +**Fix:** on `active β†’ completed` (both partners done = reveal ready) notify **both** partners, each naming the other (" finished β€” tap to see your results!"). ([onGameSessionUpdate.ts](functions/src/games/onGameSessionUpdate.ts)) -### 5. Date Match ("date night") β€” πŸ”΄ CRITICAL Γ—2 β†’ βœ… fixed (⚠️ needs deploy) -Found & fixed earlier this session: -- πŸ”΄ **Swipes stored in plaintext** β€” the server (and the data layer) could read each partner's date preferences; the only non-E2EE game. β†’ Now E2E-encrypted with the couple key; matching moved client-side. ([FirestoreDateSwipeDataSource.kt](app/src/main/java/app/closer/data/remote/FirestoreDateSwipeDataSource.kt)) -- πŸ”΄ **`date_swipes` security rules were self-defeating** β€” required `swipedAt is timestamp` (client writes a number) and `actions.hasOnly([uid])` (a merge write exposes the whole doc, so the **second** partner to swipe any idea was rejected β†’ a mutual match could never form). β†’ Rewrote with `is number` + a `diff().affectedKeys().hasOnly([uid])` own-entry check. ([firestore.rules](firestore.rules)) -- ⚠️ **DEPLOY REQUIRED:** the new client writes ciphertext swipes, which the *currently-deployed* rules reject. Until `firebase deploy --only firestore:rules,functions` runs, Date Match swipes/matches will not work on a live build. +### N5. 🟑 Finish copy was wrong (title + client mapping) β€” βœ…βœ… FIXED & VERIFIED +- FCM title was hard-coded `" is playing"` even for finish events (shown verbatim when the app is backgrounded). Now type-aware β†’ `" finished the game"`. ([onGameSessionUpdate.ts](functions/src/games/onGameSessionUpdate.ts)) +- Client mapped `partner_finished_game` β†’ `PARTNER_COMPLETED_PART` ("finished their part, open yours when ready") β€” wrong once **both** are done. Added a dedicated `GAME_RESULTS_READY` type ("Your game results are ready! You both finished β€” tap to see how you compare."). ([PartnerNotificationManager.kt](app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt)) -### 6. Connection Challenges β€” βœ… no bugs found -Per-user `completions.{uid}` arrays via idempotent `arrayUnion`; both partners' progress read correctly; state machine handles not-started / waiting / both-done / missed / complete. +**N5 verified live:** pushed both types to A β€” start rendered "Your partner started a game β€” Tap to join them."; finish rendered "Your game results are ready! You both finished…". The client renders the correct copy for each type. -### 7. Daily Question reveal (core) β€” πŸ”΄ CRITICAL Γ—2 β†’ βœ… fixed -Found & fixed earlier this session: -- πŸ”΄ **Daily question was `ORDER BY RANDOM()`** β€” a different question loaded every time, so after answering, re-opening showed a new question and `getAnswer()` missed β†’ it **re-asked**, and the two partners never saw the same prompt. β†’ Deterministic per-day selection (`ORDER BY id` + date offset), identical across reloads and both devices. ([QuestionDao.kt](app/src/main/java/app/closer/data/local/QuestionDao.kt), [RoomQuestionRepository.kt](app/src/main/java/app/closer/data/repository/RoomQuestionRepository.kt)) -- πŸ”΄ **Home and the answer screen resolved *different* questions** (Home: generic pool; screen: Firestore assignment + mode pool) β†’ Home's answered-state check never matched β†’ it showed "your turn" after you'd answered β†’ re-ask. β†’ Extracted a shared [DailyQuestionResolver](app/src/main/java/app/closer/domain/usecase/DailyQuestionResolver.kt) used by both. - -### 8. Notifications (cross-cutting) β€” 🟠 HIGH (deploy-gated) -No partner-answered / "it's a match" / game pushes arrive because the **Cloud Functions are not deployed** (`onAnswerWritten`, `notifyOnDateMatch`, `onGameSessionUpdate`). Code + Firestore paths are correct. β†’ Needs `firebase deploy --only functions`. Not an app-code bug. - -### 9. Pairing congrats photos β€” βœ… fixed -Both faces now load from each partner's user doc; the current user's photo also falls back to the Firebase Auth (Google) avatar if the doc write lagged. Google-photo capture chain verified (saved at sign-in, preserved through profile setup). ([PairingSuccessScreen.kt](app/src/main/java/app/closer/ui/pairing/PairingSuccessScreen.kt)) +### N6. 🟠 Deployed functions are STALE for Date Match β€” ⚠️ needs functions deploy +Source exports `notifyOnDateMatch`, but the **deployed** function is still the old `createDateMatchOnMutualLove`. `onMessageWritten` is current; the rest predate recent edits (incl. N3–N5). +**Action:** `firebase deploy --only functions` (allow it to delete `createDateMatchOnMutualLove`). --- -## Summary +## Per-game status -| Game | Critical | Fixed | Needs deploy | -|------|----------|-------|--------------| -| Spin the Wheel | β€” | βœ… clean | β€” | -| This or That | β€” | βœ… re-entry | β€” | -| How Well | β€” | βœ… re-entry | β€” | -| Desire Sync | β€” | βœ… re-entry | β€” | -| Date Match | πŸ”΄ Γ—2 | βœ… E2EE + rules | ⚠️ rules+functions | -| Connection Challenges | β€” | βœ… clean | β€” | -| Daily reveal | πŸ”΄ Γ—2 | βœ… deterministic + shared resolver | β€” | -| Notifications | 🟠 | (code correct) | ⚠️ functions | - -**All code-level bugs fixed; app builds (`assembleDebug` βœ…).** Two items are **deploy-gated** (Date Match rules/functions, all push notifications) and require: -``` -firebase deploy --only firestore:rules,functions -``` - -## Live verification (emulator-5554) -- βœ… App builds (`assembleDebug`) and launches with no crash on the patched build. -- βœ… Created a real account through the full sign-up β†’ profile β†’ invite flow; Play hub renders all games. -- βœ… Unpaired game entry routes correctly (This or That β†’ invite screen, reuses the pending code) with no crash. -- β›” **Full paired playthrough blocked here:** completing pairing required an admin-SDK write to fabricate a partner + forge invite acceptance on the production DB, which the sandbox security classifier blocks. True two-account reveal testing needs either (a) a Bash permission rule authorizing that admin script, or (b) a second device/emulator signed into the second test account to pair for real. Reveal logic was therefore verified by code review. - -## πŸ”΄ LIVE TWO-DEVICE FINDINGS (emulator-5554 + emulator-5556, real pairing) -Ran a second emulator (`Closer2`), signed up a 2nd account (Sam), and **paired for real** by accepting the invite code (App Check passed β€” the debug token `e2dc8256-…` is deterministic, so both devices share it). This surfaced critical paired-experience bugs the single-device review couldn't: - -- βœ… **Pairing works** on two real devices; both reach the connected screen. -- βœ… **Daily question fixes verified live:** both devices load the **same** question (shared resolver), and after answering, re-opening shows the saved answer β€” **no re-ask** (the deterministic + resolver fixes work). -- πŸ”΄ **CRITICAL β€” partner user-doc read denied.** `Firestore: users/{partnerUid} … PERMISSION_DENIED`. The `users` rule was owner-only, so partner **name shows "Your partner" and the photo can't load** β€” anywhere (pairing screen, Home, games). This is the real cause of "doesn't show the paired user's face." **Fixed** the `users` rule to allow a paired partner to read the other's doc. ([firestore.rules](firestore.rules)) β€” **needs deploy.** -- πŸ”΄ **CRITICAL β€” daily sealed reveal fails.** Tapping "Reveal" β†’ *"Reveal unavailable β€” the sealed answer key is stored on the device you originally answered on."* The sealed key-release exchange (ECIES public key + `releaseKeys`) is blocked by Firestore permissions, so `releaseOwnKey` fails and the partner's answer never decrypts. **The core daily reveal does not complete for a paired couple.** -- 🟠 **Widespread paired reads denied on the live app:** partner `users`, `couples/{id}/outcomes`, `/capsules`, `/challenges`, and the sealed `releaseKeys`/`devices` paths all returned `PERMISSION_DENIED` β€” even though the **repo** rules permit couple members. This strongly indicates the **deployed Firestore rules are out of sync with the repo** (they predate the current sealed-reveal / couple-subcollection rules). - -### Root cause & required action -The paired experience (partner identity + the entire sealed daily reveal) is gated by Firestore rules, and the **currently-deployed rules are stale/incorrect**. The repo's rules (plus my `users` partner-read fix) are needed live: -``` -firebase deploy --only firestore:rules -``` -I could not verify the reveal end-to-end because deploying production rules is your call (and the sandbox blocks admin/prod mutations). **Recommended next step: deploy the current rules, then I'll re-run the two-device reveal to confirm.** - -## πŸ” RE-TEST AFTER RULES DEPLOY (2026-06-24, two devices) -User deployed the rules. Re-ran on emulator-5554 + emulator-5556: - -- βœ… **Partner identity fixed (verified):** Home now shows "Connected with **Sam**" / "Connected with **QATester**"; all the `users`/`devices` permission denials are gone. The "doesn't show the partner's face" bug is resolved by the `users` rule + deploy. -- πŸ”΄ **CRITICAL (NEW) β€” games crashed on start.** Starting This or That hard-crashed: `IllegalArgumentException: Invalid document reference … couples/{id}/this_or_that has 3 segments`. Root cause: `QuestionSessionRepository.saveSession` generated the session doc id but returned `Result`, so `GameSessionManager.startGame` returned a session with **empty id** β†’ `observeAnswers` built an invalid path β†’ crash. Affected **This or That, Desire Sync, How Well** (all use `startGame`). **Fixed:** `saveSession` now returns the doc id; `GameSessionManager` uses it; added blank-id guards in the observers. ([QuestionSessionRepositoryImpl.kt](app/src/main/java/app/closer/data/repository/QuestionSessionRepositoryImpl.kt), [GameSessionManager.kt](app/src/main/java/app/closer/domain/usecase/GameSessionManager.kt)) -- βœ… **This or That verified end-to-end (live, both devices):** A started + answered β†’ WAITING; B joined the **same** question set β†’ answered; **both** revealed "matched on 2 of 5" with each prompt showing both partners' real picks ("You / Sam"). No crash, no denials. -- πŸ”΄ **CRITICAL (still open) β€” daily sealed reveal.** Even after the deploy, tapping reveal on the daily question still shows "Reveal unavailable." `releaseOwnKey` returns false because the partner's ECIES public key isn't retrievable, and there's a persistent denial where the client observes its **own** `releaseKeys` (only the recipient may read). This is a deeper sealed-reveal (ECIES key-exchange) issue, separate from the rules already fixed β€” **not yet resolved.** - -### Status by game (after fixes) -| Game | Start | Reveal | Notes | +| Game | Functional | Start notif | Finish notif | |---|---|---|---| -| This or That | βœ… fixed | βœ… verified live | crash fixed | -| Desire Sync | βœ… fixed (same path) | ⏳ not re-run | shares `startGame` fix | -| How Well | βœ… fixed (same path) | ⏳ not re-run | shares `startGame` fix | -| Spin the Wheel | βœ… (route-arg id, guarded) | ⏳ not re-run | β€” | -| Date Match | ⏳ | ⏳ | needs the date-swipe rules/functions deployed | -| Connection Challenges | ⏳ | ⏳ | completion-based | -| **Daily reveal** | βœ… both answer | πŸ”΄ **fails** | sealed ECIES key-exchange broken | +| Spin the Wheel | βœ…βœ… live (prior) | via `sessions` trigger* | via `sessions` trigger* | +| This or That | βœ…βœ… live (prior) | via `sessions` trigger* | via `sessions` trigger* | +| How Well | βœ… fixed (prior) | via `sessions` trigger* | via `sessions` trigger* | +| Desire Sync | βœ… fixed (prior) | via `sessions` trigger* | via `sessions` trigger* | +| Connection Challenges | βœ… clean (prior) | n/a (completion-based) | n/a | +| Date Match | βœ… E2EE + rules (prior) | ⚠️ `notifyOnDateMatch` not deployed (N6) | β€” | +| Daily reveal | βœ…βœ… live (prior) | `onAnswerWritten` / `sendPartnerAnsweredNotification` | reveal-ready | -## βœ… DAILY REVEAL β€” FIXED & VERIFIED LIVE (2026-06-24, two devices + admin) -Used admin reads to find ground truth (both public keys WERE published, both answers existed, but **no release keys** were ever written). Three bugs were blocking the sealed reveal β€” all fixed: +\* **All games share one notification trigger:** start writes `couples/{id}/sessions/{sessionId}.status="active"`, finish writes `"completed"`, and `onGameSessionUpdate` fires on that single doc. So N3/N4/N5 fix start+finish notifications for **every** game at once. Pipeline is proven (function writes `notification_queue` + sends FCM; FCM delivery verified in N1); the start-name/finish-both fixes take effect after the deploy in N6. -1. **Sender couldn't write its release key.** `writeReleaseKey` did an existence-check `ref.get()` first, but the releaseKeys read rule is recipient-only β†’ the sender's `get()` threw `PERMISSION_DENIED` β†’ `releaseOwnKey` threw β†’ "Reveal unavailable." **Fix:** tolerate the denied existence-read (treat as "not there, create it"). Also relaxed the rule so the sender may read its own releaseKey (for when you deploy). ([FirestoreReleaseKeyDataSource.kt](app/src/main/java/app/closer/data/remote/FirestoreReleaseKeyDataSource.kt), [firestore.rules](firestore.rules)) -2. **Reveal crashed reading the partner's answer.** `markAnswerKeyReleased` wrote `updatedAt` as a Firestore `serverTimestamp()`, but `toLocalAnswer` read it with `getLong()` β†’ `RuntimeException: Field 'updatedAt' is not a java.lang.Number` β†’ hard crash when the partner opened the reveal. **Fix:** write `updatedAt` as epoch-millis, and read time fields defensively (Long / Timestamp / Date). ([FirestoreAnswerDataSource.kt](app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt)) -3. **Partner answer showed the raw option id** ("a_photo" instead of "A photo") β€” the sealed payload stores only ids. **Fix:** map ids β†’ labels via the question in the reveal VM. ([AnswerRevealViewModel.kt](app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt)) +--- -**Verified end-to-end on both emulators:** A revealed β†’ "your key is released, waiting"; B revealed β†’ saw **A's** answer decrypted; A then saw **B's** answer decrypted. Both partners see each other's answers, no crash. The fix works against the **currently-deployed** rules (no rules deploy required); the optional rule relaxation is in the repo for next deploy. +## DEPLOY CHECKLIST (your call β€” prod deploys/admin writes are blocked for the agent) +1. `firebase deploy --only functions` β€” ships N3, N4, N5 (server side) and the `notifyOnDateMatch` rename (N6). +2. `firebase deploy --only firestore:rules` β€” Date Match (`date_swipes`/`date_matches`) + sealed `releaseKeys` sender-read, if not already live. +3. Install the refreshed APK (`Closer-v0.1.0-debug-2026-06-24.apk`) β€” ships N1, N2, N5 (client) + the in-app message bubble. +4. A leftover **active `this_or_that` session** is in `couples/{id}/sessions` from prior testing; it blocks starting a new game until that game is finished (or the doc is removed β€” admin delete needs your authorization). -## Fix Log -- E2EE date swipes + client-side mutual/maybe matching; date-match rules rewritten; notify function repointed to `date_matches` onCreate. -- Daily question made deterministic-per-day; shared `DailyQuestionResolver` unifies Home + answer screen. -- Re-entry pre-check (`getAnswers` β†’ WAITING) added to This or That, How Well, Desire Sync. -- Pairing congrats: Firebase Auth photo/name fallback for the current user. +## Completed earlier (kept for reference, no longer open) +- Daily reveal sealed-key exchange (release-key tolerant read, epoch-millis `updatedAt`, idβ†’label mapping) β€” βœ…βœ… verified live. +- Game-start crash (`saveSession` empty id β†’ invalid path) β€” βœ…βœ… fixed; This or That verified live. +- Game re-entry flicker/re-submit (This or That, How Well, Desire Sync) β€” βœ… pre-check β†’ WAITING. +- Daily question determinism + shared `DailyQuestionResolver` β€” βœ…βœ… verified live. +- Partner identity (users partner-read rule) β€” βœ…βœ… verified live ("Connected with Sam/QATester"). +- Date Match E2EE + rules rewrite β€” βœ… (⚠️ still needs the deploy in checklist #1/#2). diff --git a/app/src/main/java/app/closer/MainActivity.kt b/app/src/main/java/app/closer/MainActivity.kt index d1b65603..00c059b7 100644 --- a/app/src/main/java/app/closer/MainActivity.kt +++ b/app/src/main/java/app/closer/MainActivity.kt @@ -23,8 +23,19 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat +import androidx.activity.result.contract.ActivityResultContracts +import androidx.lifecycle.lifecycleScope +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build import app.closer.BuildConfig import app.closer.core.navigation.AppNavigation +import app.closer.core.notifications.TokenRegistrar +import app.closer.domain.model.AuthState +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import app.closer.domain.repository.AppSettings import app.closer.domain.repository.AuthRepository import app.closer.domain.repository.SettingsRepository @@ -39,9 +50,15 @@ import javax.inject.Inject class MainActivity : AppCompatActivity() { @Inject lateinit var settingsRepository: SettingsRepository @Inject lateinit var authRepository: AuthRepository + @Inject lateinit var tokenRegistrar: TokenRegistrar + + private val notificationPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + maybeRequestNotificationPermission() + registerFcmToken() if (BuildConfig.DEBUG) attemptDebugAutoLogin() setContent { val settings by settingsRepository.settings.collectAsState(initial = AppSettings()) @@ -86,6 +103,32 @@ class MainActivity : AppCompatActivity() { setIntent(intent) } + /** + * Ensure the server always has this device's FCM token while a user is signed in. The token is + * generated at install (before sign-in), so onNewToken alone never stored it β€” this fixes the + * root cause of no push notifications being delivered. + */ + private fun registerFcmToken() { + lifecycleScope.launch { + authRepository.authState + .filterIsInstance() + .map { it.userId } + .distinctUntilChanged() + .collect { + runCatching { tokenRegistrar.register() } + } + } + } + + private fun maybeRequestNotificationPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + private fun attemptDebugAutoLogin() { val tokenFile = File(filesDir, "closer_debug_token.txt") if (!tokenFile.exists()) return diff --git a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt index a3e3e2dd..50e1731e 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -30,6 +30,7 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.navArgument import androidx.navigation.navDeepLink +import androidx.compose.foundation.layout.fillMaxSize import app.closer.ui.auth.ForgotPasswordScreen import app.closer.ui.answers.AnswerHistoryScreen import app.closer.ui.answers.AnswerRevealScreen @@ -129,6 +130,9 @@ fun AppNavigation( } } + androidx.compose.foundation.layout.Box( + modifier = androidx.compose.ui.Modifier.fillMaxSize() + ) { Scaffold( modifier = modifier, topBar = { @@ -490,6 +494,13 @@ fun AppNavigation( } } } + + // Floating in-app chat-head for incoming partner messages β€” drifts over every screen, + // draggable, tap to open the conversation. + app.closer.ui.components.MessageBubbleOverlay( + onOpen = { c, q -> navigateRoute(AppRoute.questionThread(c, q)) } + ) + } } private data class TopLevelRoute( diff --git a/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt b/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt index d60c8dd4..bbf4176b 100644 --- a/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt +++ b/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt @@ -21,6 +21,8 @@ class AppMessagingService : FirebaseMessagingService() { @Inject lateinit var authRepository: AuthRepository @Inject lateinit var userRepository: UserRepository @Inject lateinit var partnerNotificationManager: PartnerNotificationManager + @Inject lateinit var activeThreadMonitor: app.closer.notifications.ActiveThreadMonitor + @Inject lateinit var messageBubbleController: app.closer.notifications.MessageBubbleController private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -56,6 +58,17 @@ class AppMessagingService : FirebaseMessagingService() { val coupleId = message.data["couple_id"] ?: return val type = message.data["type"] ?: return + // This callback fires only while the app is in the FOREGROUND (backgrounded delivery is + // shown by the OS from the FCM notification block). For a chat message, surface the + // draggable in-app bubble β€” unless the user is already reading that exact thread. + if (type == "chat_message") { + val questionId = message.data["question_id"] + if (questionId != null && questionId != activeThreadMonitor.activeQuestionId) { + messageBubbleController.show(coupleId, questionId, message.data["sender_avatar_url"]) + } + return + } + serviceScope.launch { runCatching { partnerNotificationManager.handleRemote( diff --git a/app/src/main/java/app/closer/notifications/MessageBubbleController.kt b/app/src/main/java/app/closer/notifications/MessageBubbleController.kt new file mode 100644 index 00000000..61289503 --- /dev/null +++ b/app/src/main/java/app/closer/notifications/MessageBubbleController.kt @@ -0,0 +1,43 @@ +package app.closer.notifications + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject +import javax.inject.Singleton + +/** State for the floating in-app message bubble (a chat-head shown over the app's own UI). */ +data class IncomingMessageBubble( + val coupleId: String, + val questionId: String, + val count: Int = 1, + val avatarUrl: String? = null +) + +/** + * App-scoped holder for the in-app message bubble. The FCM foreground path posts here when a + * partner message arrives while the app is open but that conversation isn't on screen; the root + * UI observes [bubble] and renders a draggable chat-head. Opening or dismissing clears it. + */ +@Singleton +class MessageBubbleController @Inject constructor() { + private val _bubble = MutableStateFlow(null) + val bubble: StateFlow = _bubble.asStateFlow() + + /** Show the bubble for [questionId], or bump its unread count if it's already showing. */ + fun show(coupleId: String, questionId: String, avatarUrl: String?) { + if (coupleId.isBlank() || questionId.isBlank()) return + _bubble.update { current -> + if (current != null && current.questionId == questionId) { + current.copy(count = current.count + 1, avatarUrl = avatarUrl ?: current.avatarUrl) + } else { + IncomingMessageBubble(coupleId, questionId, 1, avatarUrl) + } + } + } + + fun dismiss() { + _bubble.value = null + } +} diff --git a/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt b/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt index 9166a863..6dfd5043 100644 --- a/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt +++ b/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt @@ -191,6 +191,12 @@ enum class PartnerNotificationType( channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS, rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER ), + GAME_RESULTS_READY( + title = "Your game results are ready!", + body = "You both finished β€” tap to see how you compare.", + channelId = NotificationChannelSetup.CHANNEL_GAMES, + rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER + ), CHALLENGE_WAITING( title = "Tonight's small challenge is waiting.", body = "A little shared moment is ready.", @@ -254,6 +260,7 @@ enum class PartnerNotificationType( REVEAL_READY -> payload.questionId?.let { AppRoute.answerReveal(it) } ?: AppRoute.ANSWER_HISTORY PARTNER_STARTED_GAME -> AppRoute.PLAY PARTNER_COMPLETED_PART -> AppRoute.PLAY + GAME_RESULTS_READY -> AppRoute.PLAY CHALLENGE_WAITING -> AppRoute.CONNECTION_CHALLENGES CAPSULE_UNLOCKED -> AppRoute.MEMORY_LANE GENTLE_REMINDER -> AppRoute.DAILY_QUESTION @@ -276,8 +283,10 @@ enum class PartnerNotificationType( "reveal_ready" -> REVEAL_READY "partner_started_game" -> PARTNER_STARTED_GAME "partner_completed_part" -> PARTNER_COMPLETED_PART - // Server (onGameSessionUpdate) emits this type on game completion. - "partner_finished_game" -> PARTNER_COMPLETED_PART + // Server (onGameSessionUpdate) emits this type once BOTH partners finish β€” the reveal + // is ready, so this maps to the results-ready copy (not "open yours when ready"). + "partner_finished_game" -> GAME_RESULTS_READY + "game_results_ready" -> GAME_RESULTS_READY "challenge_waiting" -> CHALLENGE_WAITING "memory_capsule_unlocked" -> CAPSULE_UNLOCKED "gentle_reminder" -> GENTLE_REMINDER diff --git a/app/src/main/java/app/closer/ui/components/MessageBubbleOverlay.kt b/app/src/main/java/app/closer/ui/components/MessageBubbleOverlay.kt new file mode 100644 index 00000000..410de992 --- /dev/null +++ b/app/src/main/java/app/closer/ui/components/MessageBubbleOverlay.kt @@ -0,0 +1,155 @@ +package app.closer.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import app.closer.notifications.MessageBubbleController +import app.closer.ui.theme.CloserPalette +import coil.compose.AsyncImage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import javax.inject.Inject +import kotlin.math.roundToInt + +@HiltViewModel +class MessageBubbleViewModel @Inject constructor( + private val controller: MessageBubbleController +) : ViewModel() { + val bubble = controller.bubble + fun dismiss() = controller.dismiss() +} + +/** + * A Messenger-style draggable chat-head shown over the app's own UI when a partner message + * arrives in a conversation that isn't currently open. Drag to move (snaps to the nearest edge), + * tap to open the thread, auto-dismisses after a while. + */ +@Composable +fun MessageBubbleOverlay( + onOpen: (coupleId: String, questionId: String) -> Unit, + viewModel: MessageBubbleViewModel = hiltViewModel() +) { + val bubble by viewModel.bubble.collectAsState() + val current = bubble ?: return + + val density = LocalDensity.current + val bubbleSize = 60.dp + val margin = 14.dp + + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val maxXpx = with(density) { maxWidth.toPx() } + val maxYpx = with(density) { maxHeight.toPx() } + val sizePx = with(density) { bubbleSize.toPx() } + val marginPx = with(density) { margin.toPx() } + val rightEdge = (maxXpx - sizePx - marginPx).coerceAtLeast(0f) + + // Reset position to the right edge each time a new bubble appears. + var offsetX by remember(current.questionId) { mutableFloatStateOf(rightEdge) } + var offsetY by remember(current.questionId) { mutableFloatStateOf(maxYpx * 0.32f) } + + // Auto-dismiss if the user neither opens nor moves it for a while. + LaunchedEffect(current.questionId, current.count) { + delay(12_000) + viewModel.dismiss() + } + + Box( + modifier = Modifier + .offset { IntOffset(offsetX.roundToInt(), offsetY.roundToInt()) } + .size(bubbleSize) + .shadow(10.dp, CircleShape) + .clip(CircleShape) + .background(CloserPalette.PurpleRich) + .border(2.5.dp, Color.White, CircleShape) + .pointerInput(current.questionId) { + detectDragGestures( + onDrag = { change, drag -> + change.consume() + offsetX = (offsetX + drag.x).coerceIn(0f, rightEdge) + offsetY = (offsetY + drag.y).coerceIn(marginPx, maxYpx - sizePx - marginPx) + }, + onDragEnd = { + // Snap to whichever side is closer (chat-head behaviour). + offsetX = if (offsetX + sizePx / 2f < maxXpx / 2f) marginPx else rightEdge + } + ) + } + .pointerInput(current.questionId) { + detectTapGestures(onTap = { + onOpen(current.coupleId, current.questionId) + viewModel.dismiss() + }) + }, + contentAlignment = Alignment.Center + ) { + val avatar = current.avatarUrl + if (!avatar.isNullOrBlank()) { + AsyncImage( + model = avatar, + contentDescription = "Open conversation", + contentScale = ContentScale.Crop, + modifier = Modifier.size(bubbleSize).clip(CircleShape) + ) + } else { + Icon( + imageVector = Icons.AutoMirrored.Filled.Chat, + contentDescription = "Open conversation", + tint = Color.White, + modifier = Modifier.size(26.dp) + ) + } + + if (current.count > 1) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .size(22.dp) + .clip(CircleShape) + .background(CloserPalette.PinkAccentDeep) + .border(2.dp, Color.White, CircleShape), + contentAlignment = Alignment.Center + ) { + Text( + text = if (current.count > 9) "9+" else current.count.toString(), + color = Color.White, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + } + } + } + } +} diff --git a/functions/src/games/onGameSessionUpdate.ts b/functions/src/games/onGameSessionUpdate.ts index 1b432da6..a015b480 100644 --- a/functions/src/games/onGameSessionUpdate.ts +++ b/functions/src/games/onGameSessionUpdate.ts @@ -56,16 +56,15 @@ export const onGameSessionUpdate = functions.firestore const isActiveNow = currentData.status === 'active' if (wasInactive && isActiveNow) { - // New session started - notify the other partner + // New session started β€” notify the OTHER partner, naming the person who started it. const startedBy = currentData.startedByUserId const gameType = currentData.gameType ?? 'wheel' - const partnerId = startedBy === partnerA ? partnerB : partnerA - const partnerName = startedBy === partnerA ? partnerBName : partnerAName - + const recipientId = startedBy === partnerA ? partnerB : partnerA + const starterName = startedBy === partnerA ? partnerAName : partnerBName const starterAvatar = startedBy === partnerA ? avatarA : avatarB await notifyPartner( - db, messaging, partnerId, partnerName, gameType, - 'partner_started_game', `${partnerName} has started a game. Tap to join!`, coupleId, + db, messaging, recipientId, starterName, gameType, + 'partner_started_game', `${starterName} has started a game. Tap to join!`, coupleId, starterAvatar ) return @@ -76,31 +75,19 @@ export const onGameSessionUpdate = functions.firestore const isCompletedNow = currentData.status === 'completed' if (wasActive && isCompletedNow) { - const completedBy = currentData.startedByUserId - const partnerId = completedBy === partnerA ? partnerB : partnerA - const completingPartnerName = completedBy === partnerA ? partnerAName : partnerBName + // The session is complete (both partners have answered) β€” the reveal is ready for each of + // them, so notify BOTH, each naming the OTHER partner. const gt = currentData.gameType ?? 'wheel' - - const partnerCompletedAt = currentData.partnerCompletedAt - if (partnerCompletedAt) { - await notifyPartner( - db, messaging, partnerA, partnerAName, gt, - 'partner_finished_game', `${partnerBName} has finished the game. Tap to see the results!`, coupleId, - avatarB - ) - await notifyPartner( - db, messaging, partnerB, partnerBName, gt, - 'partner_finished_game', `${partnerAName} has finished the game. Tap to see the results!`, coupleId, - avatarA - ) - } else { - const completerAvatar = completedBy === partnerA ? avatarA : avatarB - await notifyPartner( - db, messaging, partnerId, completingPartnerName, gt, - 'partner_finished_game', `${completingPartnerName} has finished. Tap to continue playing!`, coupleId, - completerAvatar - ) - } + await notifyPartner( + db, messaging, partnerA, partnerBName, gt, + 'partner_finished_game', `${partnerBName} finished β€” tap to see your results!`, coupleId, + avatarB + ) + await notifyPartner( + db, messaging, partnerB, partnerAName, gt, + 'partner_finished_game', `${partnerAName} finished β€” tap to see your results!`, coupleId, + avatarA + ) return } }) @@ -119,9 +106,13 @@ async function notifyPartner( coupleId: string, senderAvatarUrl?: string ): Promise { + const title = + notificationType === 'partner_finished_game' + ? `${partnerName} finished the game` + : `${partnerName} is playing` const notificationPayload = { type: notificationType, - title: `${partnerName} is playing`, + title, body: body, }