feat(notifications): FCM token registration on auth, Android 13 permission request, in-app chat bubble overlay, game results notification copy

- MainActivity: request POST_NOTIFICATIONS on TIRAMISU+, register FCM token when user signs in
- AppMessagingService: foreground chat messages show draggable bubble instead of OS notification
- MessageBubbleController/Overlay: new in-app chat-head that drifts over all screens, tap to open
- PartnerNotificationManager: GAME_RESULTS_READY type with proper copy, partner_finished_game maps to it
- onGameSessionUpdate: notify BOTH partners on completion (not just the non-starter), fix starter name in notification
This commit is contained in:
null 2026-06-24 11:47:49 -05:00
parent 0cb3d44f0d
commit 609ced4095
8 changed files with 348 additions and 140 deletions

View File

@ -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 *"<their own partner-name> 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 ("<name> 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 `"<name> is playing"` even for finish events (shown verbatim when the app is backgrounded). Now type-aware → `"<name> 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. N3N5).
**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<Unit>`, 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).

View File

@ -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<AuthState.Authenticated>()
.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

View File

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

View File

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

View File

@ -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<IncomingMessageBubble?>(null)
val bubble: StateFlow<IncomingMessageBubble?> = _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
}
}

View File

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

View File

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

View File

@ -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<void> {
const title =
notificationType === 'partner_finished_game'
? `${partnerName} finished the game`
: `${partnerName} is playing`
const notificationPayload = {
type: notificationType,
title: `${partnerName} is playing`,
title,
body: body,
}