feat(qa): clear Future.md backlog — inclusive gender, turn-aware copy, push budgets, paywall polish, auth rotator

Implements the QA improvement backlog from Future.md:
- Inclusive sex/gender options (Female/Male/Non-binary/Prefer not to say) in onboarding +
  Edit Profile; honest copy (Desire Sync is already gender-neutral, no tailoring fallback needed).
- Turn-aware Home "waiting to play" copy ("Your turn to play.").
- Partner-action/results pushes exempt from the weekly promotional rate-limit ceiling
  (NotificationRateLimiter); reminders still bound by it. Tests updated.
- Suppress the redundant results / "partner finished" push when the recipient is already on that
  game's screen — new ActiveGameSessionMonitor (mirrors ActiveThreadMonitor), wired into the
  This or That / How Well / Desire Sync VMs + Wheel results; guarded in PartnerNotificationManager.
- Paywall: retry-with-backoff, offline-aware error copy, Continue hidden until plans load.
- Privacy-message rotator on Sign up + Forgot password (Login already had it).

iOS illustrations were already wired into the Android empty states (no change needed).
Brand-glyph G-set remains in Future.md — blocked on generated art.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-06-25 16:00:58 -05:00
parent 95cad84cb5
commit f47c8e2b64
20 changed files with 277 additions and 115 deletions

View File

@ -7,32 +7,25 @@ Non-blocking ideas: things that work today but could be better, plus feature ide
Improvement & feature ideas surfaced while QA-testing as a consumer (each works today — none are defects).
- **Inclusive sex/gender options in onboarding.** Profile step 2 ("What's your sex?") offers only **Female / Male**,
and it drives Desire Sync question tailoring. For a couples app, consider adding a non-binary / "prefer to
self-describe" / "prefer not to say" option (with a sensible tailoring fallback). *Prompted by:* Pass G sign-up flow.
- **Home "waiting to play" copy doesn't say whose turn it is.** Both partners' Home shows "**Your** partner is waiting
to play" for the *same* session, so each thinks the other is mid-game. Consider turn-aware copy ("Your turn —
finish your part" vs "Waiting on {partner}"). *Prompted by:* B-002 (resume is fixed; this is the copy nuance).
- **Exempt high-value/user-initiated pushes from the promotional rate limit.** Game-completion ("results ready") and
similar pushes share the same 20/day, 100/week cap as reminders/re-engagement. During normal heavy use a legitimate
results-ready push was suppressed. Consider a separate (higher/uncapped) budget for partner-action/results pushes vs
promotional reminders. *Prompted by:* Round 5 E-003 results-ready verification.
- **Suppress the redundant "tap to see results" push when the recipient is already on the results screen.** When both
partners finish a game at nearly the same time, both are already viewing results yet both still receive the
results-ready push. Mirror the existing chat-message guard (`activeThreadMonitor`) for the active game/results screen.
*Prompted by:* Round 5 results-ready test.
- **Friendlier paywall empty/error state.** A-OBS fixed the raw SDK error leak; as a follow-up, when plans genuinely
can't load, consider a retry-with-backoff + an offline-aware message, and hide the disabled "Continue" until plans
load. *Prompted by:* A-OBS (env had no RevenueCat sandbox, but the state is worth polishing for real failures).
- **Wire existing iOS illustrations into Android (brand uplift, low effort).** `docs/brand/asset-system.md` notes the
couple illustrations exist on iOS but several Android screens don't show them yet (answer history, invite, daily-
question, together-empty, partner-activation empty states). Mirroring the PNGs into `drawable-nodpi/` and using them
would noticeably warm up empty states with no new art needed. *Prompted by:* Pass H branding review.
- **Consistent brand glyphs across game cards + waiting/notification surfaces.** Game cards (Play hub), the
WaitingForPartner screen, and notifications mix Material icons with brand art. A small custom glyph set
(heart-of-two-halves, paired/sealed card, daily card, capsule, date-card, quiet-hours moon) used consistently would
strengthen identity. See `ClaudeBrandingReview.md` "G-set". *Prompted by:* Pass H branding review.
- **Rotating privacy messages on more entry surfaces.** The approved privacy-message rotation is a strong, on-brand
reassurance; consider surfacing it on the sign-up/login/forgot-password screens (currently plain forms). *Prompted by:* Pass H.
- **Consistent brand glyphs across game cards + waiting/notification surfaces.** _(Blocked: needs the
generated G-set art — image generation is the user's step per `ClaudeBrandingReview.md`.)_ Game cards
(Play hub), the WaitingForPartner screen, and notifications mix Material icons with brand art. A small
custom glyph set (the C-heart-keyhole mark, paired/sealed card, daily card, capsule, date-card,
quiet-hours moon) used consistently would strengthen identity. Generate the G-set, drop the assets in,
then wire them in. *Prompted by:* Pass H branding review.
> Artwork to generate (ChatGPT prompts, house-style-matched) lives in `ClaudeBrandingReview.md`, not here.
<!--
Completed (2026-06-25) and removed from the backlog:
- Inclusive sex/gender options in onboarding (Female/Male/Non-binary/Prefer not to say) + honest copy
(Desire Sync is already gender-neutral, so no tailoring fallback was needed).
- Turn-aware Home "waiting to play" copy ("Your turn to play.").
- Partner-action/results pushes exempt from the weekly promotional rate-limit ceiling.
- Suppress the redundant results / "partner finished" push when the recipient is already on that
game's screen (new ActiveGameSessionMonitor mirroring ActiveThreadMonitor).
- Friendlier paywall error state: retry-with-backoff, offline-aware message, Continue hidden until plans load.
- Wire iOS illustrations into Android empty states — already wired (history, invite, daily-question,
together-empty, partner-activation all show their illustration).
- Rotating privacy messages on sign-up + forgot-password (login already had it).
-->

View File

@ -38,12 +38,14 @@ object NotificationModule {
settingsRepository: SettingsRepository,
quietHoursManager: QuietHoursManager,
rateLimiter: NotificationRateLimiter,
activeThreadMonitor: app.closer.notifications.ActiveThreadMonitor
activeThreadMonitor: app.closer.notifications.ActiveThreadMonitor,
activeGameSessionMonitor: app.closer.notifications.ActiveGameSessionMonitor
): PartnerNotificationManager = PartnerNotificationManager(
context,
settingsRepository,
quietHoursManager,
rateLimiter,
activeThreadMonitor
activeThreadMonitor,
activeGameSessionMonitor
)
}

View File

@ -0,0 +1,29 @@
package app.closer.notifications
import javax.inject.Inject
import javax.inject.Singleton
/**
* Tracks which game session (by sessionId) the user is currently viewing set on entering a
* game / results screen, cleared on leaving so a redundant "your results are ready" or
* "your partner finished their part" push can be suppressed while they are already on that
* session live (e.g. both partners finish at nearly the same time and are both on the results
* screen).
*
* Mirrors [ActiveThreadMonitor]. Foreground-only by nature: when the app is backgrounded the OS
* auto-displays the push and this monitor isn't consulted which is the desired behaviour.
*/
@Singleton
class ActiveGameSessionMonitor @Inject constructor() {
@Volatile
var activeSessionId: String? = null
private set
fun enter(sessionId: String) {
if (sessionId.isNotBlank()) activeSessionId = sessionId
}
fun leave(sessionId: String) {
if (activeSessionId == sessionId) activeSessionId = null
}
}

View File

@ -15,6 +15,11 @@ import java.util.concurrent.TimeUnit
* - 1 reminder notification per day (proactive nudges stay gentle)
* - 100 total notifications per week
*
* Budget separation: **partner-action / high-value pushes (game start/finish, results-ready,
* partner answered, date match) are NOT subject to the weekly promotional ceiling** they are the
* core loop and should always reach the partner, bounded only by the generous 20/day anti-runaway
* cap. The 100/week ceiling exists to stop runaway *promotional* sends, so it only gates REMINDERs.
*
* Note: chat messages are NOT throttled here foreground messages show the in-app bubble and
* backgrounded ones are displayed by the OS from the FCM notification block, both bypassing this.
*
@ -48,11 +53,13 @@ class NotificationRateLimiter(context: Context) {
*/
fun canSend(type: Type): Boolean {
resetIfNewWindows()
if (isTotalOverLimit()) return false
return when (type) {
// Core-loop partner-action pushes: only the daily anti-runaway cap, NOT the weekly
// promotional ceiling — a legitimate results-ready push must not be suppressed by it.
Type.PARTNER_TRIGGER -> prefs.getInt(KEY_PARTNER_COUNT, 0) < MAX_PARTNER_PER_DAY
Type.REMINDER -> prefs.getInt(KEY_REMINDER_COUNT, 0) < MAX_REMINDER_PER_DAY
// Promotional reminders stay bound by both the daily and the weekly ceilings.
Type.REMINDER ->
!isTotalOverLimit() && prefs.getInt(KEY_REMINDER_COUNT, 0) < MAX_REMINDER_PER_DAY
}
}

View File

@ -35,7 +35,8 @@ class PartnerNotificationManager @Inject constructor(
private val settingsRepository: SettingsRepository,
private val quietHoursManager: QuietHoursManager,
private val rateLimiter: NotificationRateLimiter,
private val activeThreadMonitor: ActiveThreadMonitor
private val activeThreadMonitor: ActiveThreadMonitor,
private val activeGameSessionMonitor: ActiveGameSessionMonitor
) {
/**
@ -61,6 +62,14 @@ class PartnerNotificationManager @Inject constructor(
payload.conversationId == activeThreadMonitor.activeConversationId
) return
// Don't ping about results / "partner finished" for a game the user is already viewing live
// (both partners finishing at once would otherwise each get a redundant results push).
if ((type == PartnerNotificationType.GAME_RESULTS_READY ||
type == PartnerNotificationType.PARTNER_COMPLETED_PART) &&
payload.gameSessionId != null &&
payload.gameSessionId == activeGameSessionMonitor.activeSessionId
) return
if (!isEnabled(type, settings)) return
if (quietHoursManager.isInQuietHours(settings.quietHours)) return
if (!rateLimiter.canSend(type.rateType)) return

View File

@ -20,6 +20,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import app.closer.ui.components.BrandMessageRotator
import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@ -136,6 +137,8 @@ fun ForgotPasswordScreen(
color = AuthMuted,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(20.dp))
BrandMessageRotator(color = AuthMuted)
Spacer(Modifier.height(32.dp))
OutlinedTextField(
value = state.email,

View File

@ -29,6 +29,7 @@ import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import app.closer.ui.components.BrandMessageRotator
import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@ -137,6 +138,9 @@ fun SignUpScreen(
textAlign = TextAlign.Center
)
Spacer(Modifier.height(20.dp))
BrandMessageRotator(color = AuthMuted)
Spacer(Modifier.height(32.dp))
OutlinedTextField(

View File

@ -123,7 +123,8 @@ class DesireSyncViewModel @Inject constructor(
private val repository: QuestionRepository,
private val gameSessionManager: GameSessionManager,
private val dataSource: FirestoreDesireSyncDataSource,
private val premiumChecker: CouplePremiumChecker
private val premiumChecker: CouplePremiumChecker,
private val activeGameSessionMonitor: app.closer.notifications.ActiveGameSessionMonitor
) : ViewModel() {
private val _uiState = MutableStateFlow(DesireSyncUiState())
@ -133,6 +134,11 @@ class DesireSyncViewModel @Inject constructor(
private var partnerId: String? = null
private var coupleId: String? = null
private var sessionId: String? = null
override fun onCleared() {
super.onCleared()
sessionId?.let { activeGameSessionMonitor.leave(it) }
}
private var observeJob: Job? = null
/** True once this user's picks are written, so quitting won't cancel the shared session. */
@ -204,6 +210,7 @@ class DesireSyncViewModel @Inject constructor(
when {
startResult.isSuccess -> {
sessionId = startResult.getOrThrow()
sessionId?.let { activeGameSessionMonitor.enter(it) }
_uiState.update { it.copy(phase = DesireSyncPhase.INTRO, questions = questions) }
observeReveal()
}
@ -219,6 +226,7 @@ class DesireSyncViewModel @Inject constructor(
/** Second partner: join the in-flight session with the identical question set, same order. */
private suspend fun joinSession(existingSessionId: String, questionIds: List<String>) {
sessionId = existingSessionId
activeGameSessionMonitor.enter(existingSessionId)
val byId = loadNeutralQuestions().associateBy { it.id }
val questions = questionIds.mapNotNull { byId[it] }
if (questions.isEmpty()) return fail("Couldn't load this round's questions.")

View File

@ -619,9 +619,9 @@ class HomeViewModel @Inject constructor(
)
Priority.GAME_WAITING -> HomeAction(
eyebrow = "Game waiting",
title = "Your partner is waiting to play.",
body = "A game is ready for the two of you. Jump back in and keep the ritual going.",
eyebrow = "Your turn",
title = "Your turn to play.",
body = "Your partner already played their part — take your turn to reveal how you two line up.",
cta = "Play now",
target = HomeActionTarget.Game,
tone = HomeActionTone.Ritual,

View File

@ -157,7 +157,8 @@ class HowWellViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val repository: QuestionRepository,
private val gameSessionManager: GameSessionManager,
private val dataSource: FirestoreHowWellDataSource
private val dataSource: FirestoreHowWellDataSource,
private val activeGameSessionMonitor: app.closer.notifications.ActiveGameSessionMonitor
) : ViewModel() {
private val _uiState = MutableStateFlow(HowWellUiState())
@ -167,6 +168,11 @@ class HowWellViewModel @Inject constructor(
private var partnerId: String? = null
private var coupleId: String? = null
private var sessionId: String? = null
override fun onCleared() {
super.onCleared()
sessionId?.let { activeGameSessionMonitor.leave(it) }
}
private var startedByUserId: String? = null
private val myAnswers = mutableListOf<HowWellRawAnswer>()
private var observeJob: Job? = null
@ -237,6 +243,7 @@ class HowWellViewModel @Inject constructor(
when {
startResult.isSuccess -> {
sessionId = startResult.getOrThrow()
sessionId?.let { activeGameSessionMonitor.enter(it) }
startedByUserId = uid
_uiState.update {
it.copy(phase = HowWellPhase.INTRO, questions = questions, amSubject = true)
@ -260,6 +267,7 @@ class HowWellViewModel @Inject constructor(
questionIds: List<String>
) {
sessionId = existingSessionId
activeGameSessionMonitor.enter(existingSessionId)
startedByUserId = startedBy
val questions = questionIds.mapNotNull { repository.getQuestionById(it) }
if (questions.isEmpty()) return fail("Couldn't load this round's questions.")

View File

@ -269,14 +269,14 @@ private fun SexStep(
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "What's your sex?",
text = "How do you identify?",
style = MaterialTheme.typography.headlineMedium,
color = AuthInk,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(12.dp))
Text(
text = "Some questions — especially in Desire Sync — are tailored based on your sex. This just helps us show you the right ones.",
text = "This helps us personalize a few things. Pick what fits best — you can change it anytime in Settings.",
style = MaterialTheme.typography.bodyMedium,
color = AuthMuted,
textAlign = TextAlign.Center
@ -295,6 +295,18 @@ private fun SexStep(
selected = state.sex == "male",
onClick = { onSexSelected("male") }
)
Spacer(Modifier.height(12.dp))
SexOption(
label = "Non-binary",
selected = state.sex == "nonbinary",
onClick = { onSexSelected("nonbinary") }
)
Spacer(Modifier.height(12.dp))
SexOption(
label = "Prefer not to say",
selected = state.sex == "unspecified",
onClick = { onSexSelected("unspecified") }
)
state.sexError?.let {
Spacer(Modifier.height(12.dp))

View File

@ -90,7 +90,7 @@ class CreateProfileViewModel @Inject constructor(
}
ProfileStep.SEX -> {
if (state.sex.isBlank()) {
_uiState.update { it.copy(sexError = "Please select an option so we can tailor your experience.") }
_uiState.update { it.copy(sexError = "Please choose an option to continue.") }
} else {
_uiState.update { it.copy(currentStep = ProfileStep.PHOTO, sexError = null) }
}
@ -108,7 +108,7 @@ class CreateProfileViewModel @Inject constructor(
_uiState.update {
it.copy(
nameError = if (name.isBlank()) "Please enter your name." else if (name.length < 2) "Name must be at least 2 characters." else null,
sexError = if (state.sex.isBlank()) "Please select your sex." else null
sexError = if (state.sex.isBlank()) "Please choose an option." else null
)
}
return

View File

@ -118,11 +118,14 @@ fun PaywallScreen(
modifier = Modifier.fillMaxWidth()
)
uiState.error != null -> ErrorState(
title = "Couldn't load plans",
title = if (uiState.isOffline) "You're offline" else "Couldn't load plans",
// Always show friendly copy — the raw billing/RevenueCat SDK message (e.g.
// "There was a credentials issue. Check the underlying error…") is developer
// detail and must never surface to users. A-OBS.
message = "We couldn't load subscription options right now. Check your connection and tap to try again.",
message = if (uiState.isOffline)
"You're offline. Reconnect to see subscription options, then tap to try again."
else
"We couldn't load subscription options right now. Check your connection and tap to try again.",
retryLabel = "Try again",
onRetry = { viewModel.retry() },
modifier = Modifier.fillMaxWidth()
@ -141,6 +144,7 @@ fun PaywallScreen(
}
ActionButtons(
showContinue = uiState.packages.isNotEmpty(),
canPurchase = uiState.selectedPackage != null && uiState.purchaseState !is BillingState.Loading,
onPurchase = {
val activity = context.findActivity()
@ -366,6 +370,7 @@ private fun PlanRow(
@Composable
private fun ActionButtons(
showContinue: Boolean,
canPurchase: Boolean,
onPurchase: () -> Unit,
onRestore: () -> Unit,
@ -376,6 +381,8 @@ private fun ActionButtons(
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Only show Continue once plans have loaded — never a dead, disabled button. (Future.md QA)
if (showContinue) {
Button(
onClick = onPurchase,
enabled = canPurchase,
@ -390,6 +397,7 @@ private fun ActionButtons(
) {
Text("Continue", fontWeight = FontWeight.SemiBold)
}
}
Card(
modifier = Modifier.fillMaxWidth(),

View File

@ -1,6 +1,9 @@
package app.closer.ui.paywall
import android.app.Activity
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.domain.repository.BillingRepository
@ -9,7 +12,9 @@ import com.revenuecat.purchases.CustomerInfo
import com.revenuecat.purchases.Offerings
import com.revenuecat.purchases.Package
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -22,7 +27,9 @@ data class PaywallUiState(
val selectedPackage: Package? = null,
val customerInfo: CustomerInfo? = null,
val purchaseState: BillingState<*>? = null,
val error: String? = null
val error: String? = null,
/** True when the last load failure happened with no network — drives an offline-specific message. */
val isOffline: Boolean = false
) {
val packages: List<Package>
get() = offerings?.current?.availablePackages ?: emptyList()
@ -30,7 +37,8 @@ data class PaywallUiState(
@HiltViewModel
class PaywallViewModel @Inject constructor(
private val billingRepository: BillingRepository
private val billingRepository: BillingRepository,
@ApplicationContext private val context: Context
) : ViewModel() {
private val _uiState = MutableStateFlow(PaywallUiState())
@ -43,23 +51,42 @@ class PaywallViewModel @Inject constructor(
private fun loadOfferings() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
_uiState.update { it.copy(isLoading = true, error = null, isOffline = false) }
// Transient billing/network hiccups are common on cold start — retry a few times with
// exponential backoff before surfacing an error to the user.
repeat(MAX_LOAD_ATTEMPTS) { attempt ->
when (val result = billingRepository.getOfferings()) {
is BillingState.Loading -> _uiState.update { it.copy(isLoading = true) }
is BillingState.Success -> _uiState.update {
is BillingState.Loading -> Unit
is BillingState.Success -> {
_uiState.update {
it.copy(
isLoading = false,
error = null,
isOffline = false,
offerings = result.data,
selectedPackage = result.data.current?.availablePackages?.firstOrNull()
)
}
is BillingState.Error -> _uiState.update {
it.copy(isLoading = false, error = result.message)
return@launch
}
is BillingState.Error -> Unit
}
if (attempt < MAX_LOAD_ATTEMPTS - 1) {
delay(RETRY_BASE_DELAY_MS * (1L shl attempt))
}
}
// All attempts failed — show a safe, offline-aware message (never the raw SDK text). A-OBS.
_uiState.update { it.copy(isLoading = false, isOffline = isOffline(), error = LOAD_FAILED) }
}
}
/** Best-effort connectivity check to choose between the offline and generic error copy. */
private fun isOffline(): Boolean = runCatching {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
val caps = cm?.getNetworkCapabilities(cm.activeNetwork)
caps == null || !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}.getOrDefault(false)
private fun observeCustomerInfo() {
viewModelScope.launch {
billingRepository.getCustomerInfo().collect { state ->
@ -111,4 +138,11 @@ class PaywallViewModel @Inject constructor(
}
fun retry() = loadOfferings()
companion object {
private const val MAX_LOAD_ATTEMPTS = 3
private const val RETRY_BASE_DELAY_MS = 600L
// Marker only — the screen renders a curated, offline-aware message; the raw value is never shown.
private const val LOAD_FAILED = "load_failed"
}
}

View File

@ -316,13 +316,14 @@ fun EditProfileContent(
)
Spacer(Modifier.height(8.dp))
Text(
text = "We use this to show you the most relevant questions in Desire Sync and other features.",
text = "This helps us personalize a few things. You can change it anytime.",
style = MaterialTheme.typography.bodyMedium,
color = SettingsMuted,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(12.dp))
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
@ -340,6 +341,24 @@ fun EditProfileContent(
modifier = Modifier.weight(1f)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
SexEditOption(
label = "Non-binary",
selected = state.sex == "nonbinary",
onClick = { viewModel.selectSex("nonbinary") },
modifier = Modifier.weight(1f)
)
SexEditOption(
label = "Prefer not to say",
selected = state.sex == "unspecified",
onClick = { viewModel.selectSex("unspecified") },
modifier = Modifier.weight(1f)
)
}
}
state.sexError?.let {
Spacer(Modifier.height(8.dp))
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)

View File

@ -92,7 +92,7 @@ class EditProfileViewModel @Inject constructor(
return
}
state.sex.isBlank() -> {
_uiState.update { it.copy(sexError = "Please select your sex.") }
_uiState.update { it.copy(sexError = "Please choose an option.") }
return
}
}

View File

@ -152,7 +152,8 @@ class ThisOrThatViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val repository: QuestionRepository,
private val gameSessionManager: GameSessionManager,
private val dataSource: FirestoreThisOrThatDataSource
private val dataSource: FirestoreThisOrThatDataSource,
private val activeGameSessionMonitor: app.closer.notifications.ActiveGameSessionMonitor
) : ViewModel() {
private val _uiState = MutableStateFlow(ThisOrThatUiState())
@ -162,6 +163,11 @@ class ThisOrThatViewModel @Inject constructor(
private var partnerId: String? = null
private var coupleId: String? = null
private var sessionId: String? = null
override fun onCleared() {
super.onCleared()
sessionId?.let { activeGameSessionMonitor.leave(it) }
}
private var observeJob: Job? = null
/** True once this user's picks are written, so quitting won't cancel the shared session. */
@ -239,6 +245,7 @@ class ThisOrThatViewModel @Inject constructor(
when {
startResult.isSuccess -> {
sessionId = startResult.getOrThrow()
sessionId?.let { activeGameSessionMonitor.enter(it) }
_uiState.update { it.copy(phase = TotPhase.PLAYING, questions = picked) }
observeReveal()
}
@ -254,6 +261,7 @@ class ThisOrThatViewModel @Inject constructor(
/** Second partner: join the in-flight session with the exact same prompts, in the same order. */
private suspend fun joinSession(existingSessionId: String, questionIds: List<String>) {
sessionId = existingSessionId
activeGameSessionMonitor.enter(existingSessionId)
val byId = runCatching { repository.getQuestionsByType("this_or_that") }
.getOrElse { emptyList() }
.associateBy { it.id }

View File

@ -92,7 +92,8 @@ data class WheelCompleteUiState(
class WheelCompleteViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val gameSessionManager: GameSessionManager,
private val answerDataSource: FirestoreWheelAnswerDataSource
private val answerDataSource: FirestoreWheelAnswerDataSource,
private val activeGameSessionMonitor: app.closer.notifications.ActiveGameSessionMonitor
) : ViewModel() {
private val sessionId: String = savedStateHandle["sessionId"] ?: ""
@ -105,9 +106,15 @@ class WheelCompleteViewModel @Inject constructor(
private var partnerId: String? = null
init {
activeGameSessionMonitor.enter(sessionId)
observe()
}
override fun onCleared() {
super.onCleared()
activeGameSessionMonitor.leave(sessionId)
}
private fun observe() {
viewModelScope.launch {
val uid = gameSessionManager.currentUserId

View File

@ -11,28 +11,31 @@ import org.junit.Test
class NotificationRateLimiterTest {
private fun createLimiter(): NotificationRateLimiter {
private fun createLimiter(): Pair<NotificationRateLimiter, SharedPreferences> {
val prefs = InMemorySharedPreferences()
val context = mockk<Context>()
every { context.getSharedPreferences("notification_rate_limits", Context.MODE_PRIVATE) } returns InMemorySharedPreferences()
return NotificationRateLimiter(context)
every { context.getSharedPreferences("notification_rate_limits", Context.MODE_PRIVATE) } returns prefs
return NotificationRateLimiter(context) to prefs
}
@Test
fun `partner trigger limit allows up to two per day`() {
val limiter = createLimiter()
fun `partner trigger allows up to the daily cap`() {
val (limiter, _) = createLimiter()
repeat(NotificationRateLimiter.MAX_PARTNER_PER_DAY) {
assertTrue(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER))
limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER)
assertTrue(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER))
limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER)
}
assertFalse(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER))
assertEquals(2, limiter.count(NotificationRateLimiter.Type.PARTNER_TRIGGER))
assertEquals(
NotificationRateLimiter.MAX_PARTNER_PER_DAY,
limiter.count(NotificationRateLimiter.Type.PARTNER_TRIGGER)
)
}
@Test
fun `reminder limit allows one per day`() {
val limiter = createLimiter()
val (limiter, _) = createLimiter()
assertTrue(limiter.canSend(NotificationRateLimiter.Type.REMINDER))
limiter.record(NotificationRateLimiter.Type.REMINDER)
@ -42,23 +45,26 @@ class NotificationRateLimiterTest {
}
@Test
fun `weekly total limit blocks when four notifications have been recorded`() {
val limiter = createLimiter()
fun `weekly total ceiling blocks reminders but exempts partner-action pushes`() {
val (limiter, prefs) = createLimiter()
// Record four partner-trigger notifications directly so the weekly total
// reaches its cap independent of the daily partner-trigger limit.
repeat(4) {
// Establish the current day/week window, then push the weekly total to its ceiling
// while keeping the partner daily count low.
limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER)
}
prefs.edit()
.putInt("total_count", NotificationRateLimiter.MAX_TOTAL_PER_WEEK)
.putInt("partner_count", 0)
.apply()
assertEquals(4, limiter.totalCount())
// Promotional reminders are gated by the weekly ceiling…
assertFalse(limiter.canSend(NotificationRateLimiter.Type.REMINDER))
assertFalse(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER))
// …but core-loop partner-action pushes are NOT.
assertTrue(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER))
}
@Test
fun `mixed types count toward weekly total`() {
val limiter = createLimiter()
val (limiter, _) = createLimiter()
assertTrue(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER))
limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER)
@ -70,7 +76,7 @@ class NotificationRateLimiterTest {
@Test
fun `reminder does not count toward partner daily limit`() {
val limiter = createLimiter()
val (limiter, _) = createLimiter()
repeat(2) {
limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER)

View File

@ -27,6 +27,8 @@ class PartnerNotificationManagerTest {
private val settingsRepository = mockk<SettingsRepository>()
private val quietHoursManager = mockk<QuietHoursManager>()
private val rateLimiter = mockk<NotificationRateLimiter>(relaxed = true)
private val activeThreadMonitor = mockk<ActiveThreadMonitor>(relaxed = true)
private val activeGameSessionMonitor = mockk<ActiveGameSessionMonitor>(relaxed = true)
private val notificationManagerCompat = mockk<NotificationManagerCompat>(relaxed = true)
private lateinit var manager: PartnerNotificationManager
@ -39,7 +41,7 @@ class PartnerNotificationManagerTest {
partnerAnsweredEnabled = true
)
)
every { quietHoursManager.isInQuietHours(any(), any()) } returns false
every { quietHoursManager.isInQuietHours(any()) } returns false
every { rateLimiter.canSend(any()) } returns true
mockkStatic(NotificationManagerCompat::from)
@ -55,7 +57,10 @@ class PartnerNotificationManagerTest {
every { anyConstructed<NotificationCompat.Builder>().setCategory(any()) } returns mockk(relaxed = true)
every { anyConstructed<NotificationCompat.Builder>().build() } returns mockk(relaxed = true)
manager = PartnerNotificationManager(context, settingsRepository, quietHoursManager, rateLimiter)
manager = PartnerNotificationManager(
context, settingsRepository, quietHoursManager, rateLimiter,
activeThreadMonitor, activeGameSessionMonitor
)
}
@After