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:
parent
0cb3d44f0d
commit
609ced4095
157
ClaudeReport.md
157
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 *"<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. 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<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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue