From 0b5ba38004e993f4cec143b309c1818aeefdb6dc Mon Sep 17 00:00:00 2001 From: Static Date: Thu, 20 Aug 2026 14:50:58 -0400 Subject: [PATCH 1/5] feat: offer past buyers a free unlock, once, and keep the offer in Settings On 2026-08-16 a stranger bought the paid app. Jeff's own April purchase is in the same boat: his production install is Play-signed and never received the sideloaded build that wrote legacy_paid. Nothing that ever shipped to Play writes that flag, so both of them land on the free tier when 2.1.0 reaches production. The trigger is the ROLLOUT, not the price flip. The resolver never consults the store price. Their Play order identifiers are deliberately not recorded anywhere in this repo, which is public. An earlier version of this branch carried them in comments, a test and a commit message. That was a mistake and this history is the correction. The plan is a refund plus a free unlock code, not code that guesses who paid. A date-gated grandfather writer was built earlier today and thrown away on purpose: it worked, but it was only safe while a human remembered to flip the price after a compiled-in cutoff, and one forgotten ordering rule would have marked every free install paid-for-life and quietly ended the paid tier. Money back beats clever code. So this ships the channel instead of the mechanism. PaidEraClaimPolicy decides whether to offer the claim, and a one-time dialog at the top level carries it, with the Settings row as the permanent path for anyone who dismisses it and changes their mind. The same firstInstallTime date logic that was too dangerous in a writer is fine here, and the asymmetry is the whole argument. Nothing in this grants entitlement. A wrong date costs somebody a dialog they dismiss once, not the existence of the paid tier. Which flips the bias: over-showing is cheap, under-showing costs a real customer an unlock they will never learn was on offer, so the cutoff is set generously past the expected flip rather than tightly against it. Entitlement is OBSERVED rather than read once, because Billing answers asynchronously during startup. A single read would race queryPurchasesAsync and could offer a claim to somebody whose purchase had simply not landed yet. The claim waits for the first purchase query to settle before it will offer anything on a provisional free reading. Two bugs in that wait, both found by review rather than by tests: purchaseQuerySettled was set at the exits inside queryAndApply(), which withTimeoutOrNull cancels before they run, so the 30s timeout path never settled and anything waiting on it waited forever. It now lives in refreshPurchases's finally, the one place cancellation cannot skip. SETTLE_TIMEOUT_MS was 8s against a 30s billing timeout, so it expired while Play was still legitimately working and a slow query on an unlocked reinstall could offer a claim to somebody who already owned the unlock. Raised to 35s. Undercutting the layer below turns its patience into our bug. Both buttons latch the prompt as seen, not just "No thanks". Someone who tapped through to email has been served, and meeting the same dialog again next cold start reads as a bug rather than a courtesy. The email builder is shared between the dialog and the Settings row so the two cannot drift into asking for different things, and it falls back to a chooser when nothing handles mailto:. Play does not expose buyer email addresses for a paid-app order, so this really is the only channel between a past buyer and us. A dead button here means no channel at all. The claim flow verifies a claimant by the address they write from, which is also why publishing a live order id was worth undoing: verification belongs to the sender address in Console order search, never to an id somebody quotes. The claim-shown flag gets its own prefs file, deliberately not merged into EntitlementPrefs (the grandfather flag) or EntitlementCachePrefs (the Play grant, excluded from backup so a restore cannot become a portable unlock). No backup-rule file is touched, so Auto Backup picks it up by default and nobody meets the prompt twice on a new phone. Settings also gains a plain contact row under the structured report form. The studio address previously existed only inside Intent extras, so a phone with no mail client had no way to even learn where to write. It is now visible selectable text: tap to compose, long-press to copy. Also corrected the comment in EntitlementPrefs that still claimed the paid population was one person, and recorded there why the writer was built and then deleted, so it does not get rebuilt in six months. 423 tests, 0 failures, assembleDebug clean. Canary run in BOTH directions, because the dangerous failure here is silence and nobody reports a dialog they never saw: forcing shouldPrompt false turns the two "buyer is offered the claim" tests red, forcing it true turns the four guard tests red. Observed failing both ways, then reverted. Co-Authored-By: Claude Opus 5 --- .../com/ninelivesaudio/app/MainActivity.kt | 9 + .../app/entitlement/BillingManager.kt | 24 ++ .../app/entitlement/EntitlementPrefs.kt | 28 ++- .../app/entitlement/PaidEraClaimPolicy.kt | 70 ++++++ .../app/entitlement/PaidEraClaimPrefs.kt | 55 +++++ .../app/ui/settings/SettingsScreen.kt | 122 ++++++++- .../app/ui/unlock/PaidEraClaim.kt | 231 ++++++++++++++++++ .../app/entitlement/PaidEraClaimPolicyTest.kt | 98 ++++++++ 8 files changed, 631 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/ninelivesaudio/app/entitlement/PaidEraClaimPolicy.kt create mode 100644 app/src/main/java/com/ninelivesaudio/app/entitlement/PaidEraClaimPrefs.kt create mode 100644 app/src/main/java/com/ninelivesaudio/app/ui/unlock/PaidEraClaim.kt create mode 100644 app/src/test/java/com/ninelivesaudio/app/entitlement/PaidEraClaimPolicyTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/MainActivity.kt b/app/src/main/java/com/ninelivesaudio/app/MainActivity.kt index f6229aa..7c94bc2 100644 --- a/app/src/main/java/com/ninelivesaudio/app/MainActivity.kt +++ b/app/src/main/java/com/ninelivesaudio/app/MainActivity.kt @@ -55,6 +55,7 @@ import com.ninelivesaudio.app.ui.navigation.BottomNavBar import com.ninelivesaudio.app.ui.navigation.startDestinationFor import com.ninelivesaudio.app.ui.navigation.LeftNavRail import com.ninelivesaudio.app.ui.navigation.NineLivesNavHost +import com.ninelivesaudio.app.ui.unlock.PaidEraClaimDialog import com.ninelivesaudio.app.ui.navigation.Routes import com.ninelivesaudio.app.ui.theme.NineLivesAudioTheme import dagger.hilt.android.AndroidEntryPoint @@ -230,6 +231,14 @@ class MainActivity : ComponentActivity() { // Cosmic gradient background (behind all content) CosmicBackgroundGradient() + // One-time paid-era claim offer. Hosted here + // rather than on a screen so it does not depend + // on which destination the user lands on, and + // so it cannot be missed by someone who never + // opens Settings. It decides for itself whether + // to render. + PaidEraClaimDialog() + // Content stack: NavHost + MiniPlayer overlay Column(modifier = Modifier.fillMaxSize()) { NineLivesNavHost( diff --git a/app/src/main/java/com/ninelivesaudio/app/entitlement/BillingManager.kt b/app/src/main/java/com/ninelivesaudio/app/entitlement/BillingManager.kt index c45b4c7..1489fc6 100644 --- a/app/src/main/java/com/ninelivesaudio/app/entitlement/BillingManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/entitlement/BillingManager.kt @@ -93,6 +93,24 @@ class BillingManager @Inject constructor( */ val productLookupSettled: StateFlow = _productLookupSettled.asStateFlow() + private val _purchaseQuerySettled = MutableStateFlow(false) + + /** + * True once the first purchase query has finished, however it finished. + * + * Same ambiguity as [productLookupSettled], one step more dangerous. Before + * the first query answers, entitlement reads as free for everybody, because + * the Play-grant cache is deliberately excluded from backup and so does not + * survive a reinstall or a device move. Anything that acts on "user is free" + * during that window acts on a value that has not been established yet. + * + * Set in [refreshPurchases]'s `finally`, which is the one place cancellation + * cannot skip. A consumer waiting on this must not be left waiting forever by + * a device with no Play Store, or by a query that Play never answers, both of + * which are legitimate states rather than errors. + */ + val purchaseQuerySettled: StateFlow = _purchaseQuerySettled.asStateFlow() + private val client: BillingClient = BillingClient.newBuilder(context) .setListener(this) // One-time products can go PENDING (cash payments, parental approval). @@ -177,6 +195,12 @@ class BillingManager @Inject constructor( withTimeoutOrNull(BILLING_TIMEOUT_MS) { queryAndApply() } ?: Log.d(TAG, "purchase query timed out, leaving entitlement untouched") } finally { + // Settled HERE and only here. An earlier version set this at the + // exits inside queryAndApply, which withTimeoutOrNull cancels before + // they run, so the 30s timeout path never settled and anything + // waiting on it waited forever. A timeout still produces no verdict, + // but "Play did not answer" is itself the answer a waiter needs. + _purchaseQuerySettled.value = true refreshMutex.unlock() } } diff --git a/app/src/main/java/com/ninelivesaudio/app/entitlement/EntitlementPrefs.kt b/app/src/main/java/com/ninelivesaudio/app/entitlement/EntitlementPrefs.kt index 0df778a..3875dd5 100644 --- a/app/src/main/java/com/ninelivesaudio/app/entitlement/EntitlementPrefs.kt +++ b/app/src/main/java/com/ninelivesaudio/app/entitlement/EntitlementPrefs.kt @@ -31,9 +31,31 @@ import javax.inject.Singleton * symptom from the one pass most likely to catch it. * * The flag can now only arrive from an Auto Backup restore of an install that - * predates the switch. Anyone stranded without it is recovered with a promo - * code for `nine_lives_unlock`, which is affordable precisely because the paid - * population is one person. + * predates the switch. Everyone else is recovered by hand. + * + * ## The paid population is no longer just Jeff + * + * CORRECTED 2026-08-20. This used to end by saying manual recovery was + * affordable "precisely because the paid population is one person". That stopped + * being true on 2026-08-16, when a stranger bought the paid app. Since nothing shipped ever writes this + * flag, they carry no grandfather signal and land on the free tier when 2.1.0 + * reaches production. Their order identifier is deliberately not recorded in + * this repo, which is public. + * + * A date-gated writer was built to catch them and then deliberately thrown + * away. It worked, but it only stayed safe while a human remembered to flip the + * price AFTER a compiled-in cutoff, and one forgotten ordering rule would have + * grandfathered every free install and quietly ended the paid tier. Jeff's call: + * refund the buyer instead, leave them the free app, and carry the note in + * Settings offering a free unlock code to anyone who bought before the switch. + * Money back beats clever code. + * + * So manual recovery is still the plan, and it is still affordable, just for a + * different reason: the recovery path is a support email answered with a promo + * code, and the population it has to serve is tiny rather than theoretically + * zero. If real paid volume ever shows up in the order history before the flip, + * revisit this, because hand-recovery does not scale and the writer is only safe + * under a rule nobody will remember. */ @Singleton class EntitlementPrefs @Inject constructor( diff --git a/app/src/main/java/com/ninelivesaudio/app/entitlement/PaidEraClaimPolicy.kt b/app/src/main/java/com/ninelivesaudio/app/entitlement/PaidEraClaimPolicy.kt new file mode 100644 index 0000000..cd0baa0 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/entitlement/PaidEraClaimPolicy.kt @@ -0,0 +1,70 @@ +package com.ninelivesaudio.app.entitlement + +/** + * Decides whether to show the one-time "you bought this back when it cost + * money" prompt. + * + * ## Why a date here is fine, when a date in a grandfather writer was not + * + * A date-gated WRITER was built on 2026-08-20 and deliberately thrown away. It + * was only safe while a human remembered to flip the price after a compiled-in + * cutoff, and one slip would have marked every free install paid-for-life and + * quietly ended the paid tier. + * + * This is the same `firstInstallTime` mechanism pointed at a completely + * different blast radius. Nothing here grants entitlement. The worst case for a + * wrong date is that somebody who never paid sees a prompt that does not apply + * to them and taps "No thanks". That asymmetry is the whole reason this is + * allowed to exist and the writer is not. + * + * Which means the bias runs the OTHER way from the writer. Over-showing costs a + * dismissed dialog. Under-showing costs a real customer their unlock, and they + * have no other way to find out the offer exists. So [PROMPT_CUTOFF_MILLIS] is + * set generously past the expected flip rather than tightly against it. + * + * ## Why a prompt and not just the Settings row + * + * The Settings row is the permanent path, but nobody scrolls into Settings + * hunting for a refund they do not know exists. Play does not hand out buyer + * email addresses for a paid-app order, so the app is the only channel between + * a past buyer and us, and a channel nobody opens is not a channel. + */ +object PaidEraClaimPolicy { + + /** + * Installs first created before this (UTC epoch millis, 2026-12-01T00:00:00Z) + * are offered the claim prompt. + * + * Deliberately later than the expected price flip. Every install before the + * flip genuinely paid, and the slack past it only costs a few free users a + * dialog they will dismiss once. Unlike a grandfather cutoff, moving this + * later is the SAFE direction and moving it earlier is the one that strands + * people. + */ + const val PROMPT_CUTOFF_MILLIS: Long = 1796083200000L + + /** + * @param firstInstallTimeMillis `PackageInfo.firstInstallTime`. Survives + * updates, so a buyer who updates late still gets the prompt. Does not + * survive uninstall and reinstall, which is what the Settings row covers. + * @param isUnlocked already entitled, by purchase or by a restored flag, so + * there is nothing to claim. + * @param alreadyPrompted the prompt has been shown once. Once is the whole + * contract: a nag box on every cold start earns a one-star review faster + * than a missing feature does. + * + * A non-positive `firstInstallTimeMillis` means the lookup failed. Treated + * as "do not prompt", so a broken read is silent rather than showing a + * confusing dialog to every install on the error path. The Settings row + * still covers anyone that misses. + */ + fun shouldPrompt( + firstInstallTimeMillis: Long, + isUnlocked: Boolean, + alreadyPrompted: Boolean, + ): Boolean = + !isUnlocked && + !alreadyPrompted && + firstInstallTimeMillis > 0L && + firstInstallTimeMillis < PROMPT_CUTOFF_MILLIS +} diff --git a/app/src/main/java/com/ninelivesaudio/app/entitlement/PaidEraClaimPrefs.kt b/app/src/main/java/com/ninelivesaudio/app/entitlement/PaidEraClaimPrefs.kt new file mode 100644 index 0000000..777c3a0 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/entitlement/PaidEraClaimPrefs.kt @@ -0,0 +1,55 @@ +package com.ninelivesaudio.app.entitlement + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Remembers that the paid-era claim prompt has been shown, so it shows once. + * + * ## Deliberately its own file + * + * NOT merged into [EntitlementPrefs], which holds the grandfather flag and is + * the most dangerous boolean in the app, and NOT merged into + * [EntitlementCachePrefs], which holds the Play grant and is excluded from + * backup precisely so a restored grant cannot become a portable unlock. + * + * This flag grants nothing. Keeping it separate means a mistake here can never + * touch either of those, and it keeps both of those files' rules short enough + * that people actually read them. + * + * ## Backed up on purpose + * + * No backup-rule change accompanies this file, which means Auto Backup picks it + * up by default and that is the behavior we want: somebody who already + * dismissed the prompt should not meet it again on a new phone. Note that the + * backup rule files must NOT gain an `` for it, because a single + * include flips the whole section to allowlist mode and silently drops + * everything else. Both rule files carry that warning. + */ +@Singleton +class PaidEraClaimPrefs @Inject constructor( + @ApplicationContext context: Context, +) { + private val prefs = context.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE) + + val wasPrompted: Boolean + get() = prefs.getBoolean(KEY_PROMPTED, false) + + /** + * Latch the prompt as seen. + * + * `commit()` rather than `apply()`: this is written as the dialog closes, + * which is exactly when the user may be leaving the app, and an async write + * lost to a process death shows them the prompt a second time. + */ + fun markPrompted() { + prefs.edit().putBoolean(KEY_PROMPTED, true).commit() + } + + companion object { + const val FILE_NAME = "nine_lives_paid_era_claim" + const val KEY_PROMPTED = "claim_prompted" + } +} diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsScreen.kt b/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsScreen.kt index d5afd67..f815293 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.border import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -35,6 +36,10 @@ import android.provider.DocumentsContract import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow @@ -53,6 +58,7 @@ import com.ninelivesaudio.app.ui.copy.unhinged.CopyEngine import com.ninelivesaudio.app.ui.copy.unhinged.CopyStyleGuide import com.ninelivesaudio.app.ui.theme.NineLivesTheme import com.ninelivesaudio.app.ui.unlock.UnlockViewModel +import com.ninelivesaudio.app.ui.unlock.sendPaidEraClaimEmail import com.ninelivesaudio.app.ui.theme.unhinged.* /** @@ -545,7 +551,10 @@ fun SettingsScreen( // ═════════════════════════════════════════════════════════════ // Unlock // ═════════════════════════════════════════════════════════════ - UnlockSettingsGroup(onNavigateToUnlock = onNavigateToUnlock) + UnlockSettingsGroup( + onNavigateToUnlock = onNavigateToUnlock, + appVersion = uiState.appVersion, + ) SettingsGroup(title = "Experience") { ArchivePreferencesSection( @@ -856,7 +865,7 @@ fun SettingsScreen( viewModel.buildReport { subject, body -> val intent = Intent(Intent.ACTION_SENDTO).apply { data = Uri.parse("mailto:") - putExtra(Intent.EXTRA_EMAIL, arrayOf("Static@StaticHum.Studio")) + putExtra(Intent.EXTRA_EMAIL, arrayOf(STUDIO_EMAIL)) putExtra(Intent.EXTRA_SUBJECT, subject) putExtra(Intent.EXTRA_TEXT, body) } @@ -865,7 +874,7 @@ fun SettingsScreen( } else { val fallback = Intent(Intent.ACTION_SEND).apply { type = "message/rfc822" - putExtra(Intent.EXTRA_EMAIL, arrayOf("Static@StaticHum.Studio")) + putExtra(Intent.EXTRA_EMAIL, arrayOf(STUDIO_EMAIL)) putExtra(Intent.EXTRA_SUBJECT, subject) putExtra(Intent.EXTRA_TEXT, body) } @@ -875,6 +884,18 @@ fun SettingsScreen( }, ) + // Plain contact, on purpose, sitting under the structured + // report form rather than replacing it. + // + // The report form covers bugs. This covers everything else, and + // more importantly it is the only place the address is VISIBLE. + // Everywhere else it lives inside Intent extras, so a phone with + // no mail client, or somebody who would rather write from a + // laptop, currently has no way to even learn where to write. + // Tap to compose, long-press to copy, and the text is + // selectable, so all three routes work. + DirectContactRow() + HorizontalDivider(color = NineLivesTheme.colors.archiveVoidElevated, thickness = 1.dp) // Nightwatch Dossier @@ -948,10 +969,12 @@ private fun SkipSilenceRow( @Composable private fun UnlockSettingsGroup( onNavigateToUnlock: () -> Unit, + appVersion: String, viewModel: UnlockViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val restoreMessage by viewModel.restoreMessage.collectAsStateWithLifecycle() + val context = LocalContext.current SettingsGroup(title = "Unlock") { Row( @@ -1020,6 +1043,46 @@ private fun UnlockSettingsGroup( modifier = Modifier.clickable { viewModel.restorePurchases() }, ) } + + // The whole recovery path for anyone who bought the paid app. + // + // There is no automatic grandfather. Nothing shipped to Play ever wrote + // legacy_paid, and the date-gated writer built on 2026-08-20 was thrown + // away on purpose: it was only safe while a human remembered to flip the + // price after a compiled-in cutoff, and one slip would have marked every + // free install paid-for-life. See EntitlementPrefs. + // + // So this row IS the mechanism, not a net under one. Play does not hand + // out buyer email addresses for a paid-app order, which means the app is + // the only channel that exists between a past buyer and us. If this row + // does not work, nothing does. + // + // Hidden once unlocked, because there is nothing left to claim. + if (!uiState.isUnlocked) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Bought this back when it cost money?", + style = MaterialTheme.typography.bodySmall, + color = NineLivesTheme.colors.archiveTextMuted, + ) + Text( + text = "Claim", + style = MaterialTheme.typography.bodySmall.copy( + textDecoration = TextDecoration.Underline, + ), + color = NineLivesTheme.colors.goldFilament, + // Same helper the one-time prompt uses, so the two + // cannot drift into asking for different things. + modifier = Modifier.clickable { + sendPaidEraClaimEmail(context, appVersion) + }, + ) + } + } } } @@ -1605,6 +1668,59 @@ private fun ArchiveSweepConfirmDialog( ) } +private const val STUDIO_EMAIL = "Static@StaticHum.Studio" + +@Composable +private fun DirectContactRow() { + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + val haptics = LocalHapticFeedback.current + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .combinedClickable( + onClick = { + val mail = Intent(Intent.ACTION_SENDTO).apply { + data = Uri.parse("mailto:") + putExtra(Intent.EXTRA_EMAIL, arrayOf(STUDIO_EMAIL)) + putExtra(Intent.EXTRA_SUBJECT, "Nine Lives") + } + // No resolveActivity guard needed. If nothing handles it the + // address is still on screen and still copyable, which is + // the entire reason this row shows it as text. + runCatching { context.startActivity(mail) } + }, + onLongClick = { + clipboard.setText(AnnotatedString(STUDIO_EMAIL)) + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + }, + ) + .padding(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = "Something not covered here?", + style = MaterialTheme.typography.bodyMedium, + color = NineLivesTheme.colors.archiveTextPrimary, + ) + SelectionContainer { + Text( + text = STUDIO_EMAIL, + style = MaterialTheme.typography.bodyMedium, + color = NineLivesTheme.colors.goldFilament, + ) + } + Text( + text = "Tap to write, long-press to copy. It is one person back here, " + + "so give me 72 hours before you assume I am ignoring you.", + style = MaterialTheme.typography.bodySmall, + color = NineLivesTheme.colors.archiveTextMuted, + ) + } +} + // ─── Section Label (inside a group) ────────────────────────────────────── @Composable diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/unlock/PaidEraClaim.kt b/app/src/main/java/com/ninelivesaudio/app/ui/unlock/PaidEraClaim.kt new file mode 100644 index 0000000..d51b853 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/ui/unlock/PaidEraClaim.kt @@ -0,0 +1,231 @@ +package com.ninelivesaudio.app.ui.unlock + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.net.Uri +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.hilt.navigation.compose.hiltViewModel +import com.ninelivesaudio.app.entitlement.BillingManager +import com.ninelivesaudio.app.entitlement.EntitlementRepository +import com.ninelivesaudio.app.entitlement.PaidEraClaimPolicy +import com.ninelivesaudio.app.entitlement.PaidEraClaimPrefs +import com.ninelivesaudio.app.ui.theme.NineLivesTheme +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import javax.inject.Inject + +private const val SUPPORT_EMAIL = "Static@StaticHum.Studio" +private const val CLAIM_SUBJECT = "Nine Lives: paid-app unlock claim" + +/** + * Open a mail client with the paid-era claim prefilled. + * + * Shared by the Settings row and the one-time prompt so the two cannot drift + * into asking for different things. Falls back to a chooser when nothing + * handles `mailto:`, because a dead button here means a past buyer has no way + * to reach us at all: Play does not expose buyer email addresses for a + * paid-app order, so this really is the only channel. + */ +fun sendPaidEraClaimEmail(context: Context, appVersion: String) { + // No order ID asked for, on purpose. Making somebody dig through Play Store, + // then Payments and subscriptions, then Budget and history, is work we would be + // imposing on a person we already took money from. Play Console's order search + // accepts an email address, so the sender address IS the lookup key and the + // whole ask collapses to "send this". + val body = buildString { + appendLine("I bought Nine Lives Audio back when it was a paid app, and I would like the unlock.") + appendLine() + appendLine("Sending this from the Google account I bought it with, so it should be findable on your end.") + appendLine() + appendLine("App version: $appVersion") + } + val mail = Intent(Intent.ACTION_SENDTO).apply { + data = Uri.parse("mailto:") + putExtra(Intent.EXTRA_EMAIL, arrayOf(SUPPORT_EMAIL)) + putExtra(Intent.EXTRA_SUBJECT, CLAIM_SUBJECT) + putExtra(Intent.EXTRA_TEXT, body) + } + if (mail.resolveActivity(context.packageManager) != null) { + context.startActivity(mail.withNewTaskIfNeeded(context)) + return + } + val fallback = Intent(Intent.ACTION_SEND).apply { + type = "message/rfc822" + putExtra(Intent.EXTRA_EMAIL, arrayOf(SUPPORT_EMAIL)) + putExtra(Intent.EXTRA_SUBJECT, CLAIM_SUBJECT) + putExtra(Intent.EXTRA_TEXT, body) + } + context.startActivity( + Intent.createChooser(fallback, "Send claim via").withNewTaskIfNeeded(context), + ) +} + +/** + * Add FLAG_ACTIVITY_NEW_TASK when the context is not an Activity. + * + * Caught on a real device, and it would never have shown up in a unit test. + * The Settings row passes an Activity context and worked. The dialog routed + * through a ViewModel holding the application context, and ContextImpl throws + * outright rather than degrading. Applied conditionally rather than always, + * because forcing a new task from an Activity changes the back stack the mail + * client comes back to. + */ +private fun Intent.withNewTaskIfNeeded(context: Context): Intent { + var c: Context? = context + while (c is ContextWrapper) { + if (c is Activity) return this + c = c.baseContext + } + return apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } +} + +@HiltViewModel +class PaidEraClaimViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val prefs: PaidEraClaimPrefs, + private val billing: BillingManager, + entitlements: EntitlementRepository, +) : ViewModel() { + + private val _isVisible = MutableStateFlow(false) + val isVisible: StateFlow = _isVisible.asStateFlow() + + /** + * Read once and cached. `firstInstallTime` cannot change while the process + * is alive, and re-reading it per recomposition would put a binder call on + * every frame that touches this state. + */ + private val firstInstallTime: Long = runCatching { + context.packageManager.getPackageInfo(context.packageName, 0).firstInstallTime + }.getOrDefault(0L) + + init { + // WAIT for the first purchase query before deciding anything, then keep + // observing. + // + // Observing alone is not enough, which is what an earlier version of + // this comment got wrong. Before Billing answers, entitlement reads free + // for EVERYBODY, because the Play-grant cache is excluded from backup + // and does not survive a reinstall or a device move. Merely watching the + // flow would show the dialog during that window and then hide it once + // the truth arrived, so an unlock owner reinstalling would get a flash + // of a prompt offering them something they already bought. Worse, a + // reinstall resets firstInstallTime to today, which is inside the + // window, so the date gate does not save them either. + // + // Bounded, not indefinite. A device with no Play Store is a legitimate + // state, not an error, and a paid-era buyer on one still deserves the + // offer. If Billing never settles we fall through and decide on what we + // have, which for that person is the correct answer anyway. + viewModelScope.launch { + withTimeoutOrNull(SETTLE_TIMEOUT_MS) { + billing.purchaseQuerySettled.first { it } + } + entitlements.state + .map { it.isUnlocked } + .distinctUntilChanged() + .collect { isUnlocked -> + _isVisible.value = PaidEraClaimPolicy.shouldPrompt( + firstInstallTimeMillis = firstInstallTime, + isUnlocked = isUnlocked, + alreadyPrompted = prefs.wasPrompted, + ) + } + } + } + + /** + * Close the prompt and latch it as seen. + * + * Latching on EITHER button, not just "No thanks". Someone who taps through + * to email has been served, and meeting the same dialog again on the next + * cold start reads as a bug rather than a courtesy. + */ + fun dismiss() { + prefs.markPrompted() + _isVisible.value = false + } + + fun appVersion(): String = runCatching { + context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "unknown" + }.getOrDefault("unknown") + + private companion object { + /** + * Backstop for the wait on Billing, and it MUST exceed BillingManager's + * own BILLING_TIMEOUT_MS (30s). + * + * An earlier 8s value was wrong and codex caught it: it expired while + * Play was still legitimately working, so a slow query on an unlocked + * reinstall fell through to the provisional free reading and offered a + * claim to somebody who already owned the unlock. Undercutting the + * layer below turns its patience into our bug. + * + * This is only a backstop now. The settle flag is set in a `finally`, + * so the normal timeout path resolves this wait in about 30s anyway. + * A late prompt is harmless. A wrong one is not. + */ + const val SETTLE_TIMEOUT_MS = 35_000L + } +} + +/** + * One-time offer shown to installs that predate the switch to free. + * + * Hosted at the top level rather than on a screen, so it survives whatever the + * user happened to open first and does not depend on them finding Settings. + */ +@Composable +fun PaidEraClaimDialog( + viewModel: PaidEraClaimViewModel = hiltViewModel(), +) { + val isVisible by viewModel.isVisible.collectAsStateWithLifecycle() + if (!isVisible) return + + // Activity context, deliberately, not the ViewModel's application context. + // Routing the send through the ViewModel is what crashed this on device. + val context = LocalContext.current + + AlertDialog( + onDismissRequest = viewModel::dismiss, + title = { Text("You paid for this") }, + text = { + Text( + "Nine Lives is free now. You bought it back when it cost money, so the " + + "unlock is yours at no charge. Tap below and send the email from the " + + "account you bought it with. We'll find the purchase on our end and send " + + "a code back. Nothing to pay, and this is the only time we'll ask." + ) + }, + confirmButton = { + TextButton(onClick = { + sendPaidEraClaimEmail(context, viewModel.appVersion()) + viewModel.dismiss() + }) { + Text("Email for a code", color = NineLivesTheme.colors.goldFilament) + } + }, + dismissButton = { + TextButton(onClick = viewModel::dismiss) { Text("No thanks") } + }, + containerColor = NineLivesTheme.colors.archiveVoidSurface, + ) +} diff --git a/app/src/test/java/com/ninelivesaudio/app/entitlement/PaidEraClaimPolicyTest.kt b/app/src/test/java/com/ninelivesaudio/app/entitlement/PaidEraClaimPolicyTest.kt new file mode 100644 index 0000000..6a367b7 --- /dev/null +++ b/app/src/test/java/com/ninelivesaudio/app/entitlement/PaidEraClaimPolicyTest.kt @@ -0,0 +1,98 @@ +package com.ninelivesaudio.app.entitlement + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The claim prompt is the ONLY channel between a past buyer and us, because + * Play does not expose buyer email addresses for a paid-app order. A bug that + * silences it is invisible: nobody complains about a dialog they never saw. + */ +class PaidEraClaimPolicyTest { + + /** + * A real paid-era purchase, 2026-08-16 09:45:21 UTC. + * + * The Play order identifier is deliberately NOT recorded here. This repo is + * public, and the claim flow verifies a claimant by the address they write + * from, so publishing a live order id would hand strangers something to + * impersonate with. The timestamp is all the test needs. + */ + private val realBuyerInstall = 1786873521000L + + /** Jeff's own production install, 2026-04-18 15:34 UTC. Identifier omitted, same reason. */ + private val jeffsInstall = 1776526440000L + + @Test + fun `the real paid customer is offered the claim`() { + assertTrue( + LegacyFreeMessage, + PaidEraClaimPolicy.shouldPrompt(realBuyerInstall, isUnlocked = false, alreadyPrompted = false), + ) + } + + @Test + fun `Jeff's own paid install is offered the claim too`() { + // His production install is Play-signed and never received the sideloaded + // build that wrote legacy_paid, so he is in exactly the same boat. + assertTrue( + PaidEraClaimPolicy.shouldPrompt(jeffsInstall, isUnlocked = false, alreadyPrompted = false), + ) + } + + @Test + fun `an already unlocked reader is never prompted`() { + assertFalse( + "there is nothing to claim, and asking looks like a bug", + PaidEraClaimPolicy.shouldPrompt(realBuyerInstall, isUnlocked = true, alreadyPrompted = false), + ) + } + + @Test + fun `the prompt is shown once and never again`() { + assertFalse( + "a nag box on every cold start earns a one-star faster than a missing feature", + PaidEraClaimPolicy.shouldPrompt(realBuyerInstall, isUnlocked = false, alreadyPrompted = true), + ) + } + + @Test + fun `an install after the cutoff is not prompted`() { + assertFalse( + PaidEraClaimPolicy.shouldPrompt( + PaidEraClaimPolicy.PROMPT_CUTOFF_MILLIS + 1, + isUnlocked = false, + alreadyPrompted = false, + ), + ) + } + + @Test + fun `an unreadable install time is not prompted`() { + listOf(0L, -1L).forEach { bogus -> + assertFalse( + "$bogus must not show a confusing dialog to everyone on the error path", + PaidEraClaimPolicy.shouldPrompt(bogus, isUnlocked = false, alreadyPrompted = false), + ) + } + } + + /** + * Unlike a grandfather cutoff, the safe direction here is LATER. If this + * fails the release slipped past the prompt window and real buyers would be + * silently skipped, so push the constant out before shipping. + */ + @Test + fun `the prompt window has not already closed`() { + assertTrue( + "PROMPT_CUTOFF_MILLIS has passed. Push it out, or paid-era buyers get no prompt at all.", + PaidEraClaimPolicy.PROMPT_CUTOFF_MILLIS > System.currentTimeMillis(), + ) + } + + private companion object { + const val LegacyFreeMessage = + "a real paid-era buyer must be offered the free unlock" + } +} From 7925d6e93c4e607e64c852076624c197c25fb82b Mon Sep 17 00:00:00 2001 From: Static Date: Thu, 20 Aug 2026 17:21:16 -0400 Subject: [PATCH 2/5] fix: copy the address with a button, because a gesture got stolen The contact row said "long-press to copy" and, on the one spot people actually press, it did not. The address sits in a SelectionContainer nested inside the row's combinedClickable. SelectionContainer wins the long-press and starts text selection instead, and Compose selects on word boundaries, so the "@" splits the address and Copy hands back "StaticHum.Studio" with the "Static@" missing. Following the row's own printed instruction produced a broken address. Long-pressing anywhere ELSE in the row worked fine, which is exactly why this survived the first look. The failing path was the obvious one. So copy is a real button now, sitting to the right of the address. A button cannot be stolen by a gesture. The row keeps its tap-to-compose, the SelectionContainer stays for anyone who wants to select by hand, and the hint drops "long-press to copy" for "or copy the address". Verified on device rather than reasoned about, because the whole bug was a gesture nobody could see in the source: - Tapping the button shows "Copied." and the foreground activity STAYS on MainActivity, so the button consumes its own tap instead of also firing the row's compose intent. - Clipboard contents read BACK by pasting into a system text field: "Static@StaticHum.Studio", intact. That readback is the check that actually closes this, not the toast. - Row tap still resolves to the mail chooser, so the button did not break the compose path. Method note worth keeping: the Compose selection toolbar does NOT appear in a uiautomator dump, it renders in a popup window the dump misses. The programmatic check reported no toolbar while the screenshot plainly showed Copy and Select all. The bug was only visible in the picture. Also renames the report type "Upgrade Request" to "Feature Request", which is what people actually call it. Enum constant renamed to match. The subject prefix stays "[NineLives Request]" since a feature request is still a request, and changing it would break any mail filter already keyed to it. 423 tests, 0 failures, assembleDebug clean. Evidence: /home/static/nine-lives-evidence/2026-08-20-settle-bound-and-contact-row/ Co-Authored-By: Claude Opus 5 --- .../app/ui/settings/SettingsScreen.kt | 72 ++++++++++++------- .../app/ui/settings/SettingsViewModel.kt | 2 +- 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsScreen.kt b/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsScreen.kt index f815293..fa6b82f 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsScreen.kt @@ -1680,23 +1680,17 @@ private fun DirectContactRow() { modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) - .combinedClickable( - onClick = { - val mail = Intent(Intent.ACTION_SENDTO).apply { - data = Uri.parse("mailto:") - putExtra(Intent.EXTRA_EMAIL, arrayOf(STUDIO_EMAIL)) - putExtra(Intent.EXTRA_SUBJECT, "Nine Lives") - } - // No resolveActivity guard needed. If nothing handles it the - // address is still on screen and still copyable, which is - // the entire reason this row shows it as text. - runCatching { context.startActivity(mail) } - }, - onLongClick = { - clipboard.setText(AnnotatedString(STUDIO_EMAIL)) - haptics.performHapticFeedback(HapticFeedbackType.LongPress) - }, - ) + .clickable { + val mail = Intent(Intent.ACTION_SENDTO).apply { + data = Uri.parse("mailto:") + putExtra(Intent.EXTRA_EMAIL, arrayOf(STUDIO_EMAIL)) + putExtra(Intent.EXTRA_SUBJECT, "Nine Lives") + } + // No resolveActivity guard needed. If nothing handles it the + // address is still on screen and still copyable, which is + // the entire reason this row shows it as text. + runCatching { context.startActivity(mail) } + } .padding(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(2.dp), ) { @@ -1705,15 +1699,43 @@ private fun DirectContactRow() { style = MaterialTheme.typography.bodyMedium, color = NineLivesTheme.colors.archiveTextPrimary, ) - SelectionContainer { - Text( - text = STUDIO_EMAIL, - style = MaterialTheme.typography.bodyMedium, - color = NineLivesTheme.colors.goldFilament, - ) + // Copy is a real button, not a long-press, and the device pass is why. + // + // Long-press used to live on the whole row. It never fired on the part + // people actually press: the address sits in a SelectionContainer, which + // wins the long-press and starts text selection instead. Compose selects + // on word boundaries, so the "@" split the address and Copy handed back + // "StaticHum.Studio" with the "Static@" missing. Following the row's own + // printed instruction produced a broken address. A button cannot be + // stolen by a gesture, and selection still works for anyone who wants it. + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + SelectionContainer { + Text( + text = STUDIO_EMAIL, + style = MaterialTheme.typography.bodyMedium, + color = NineLivesTheme.colors.goldFilament, + ) + } + IconButton( + onClick = { + clipboard.setText(AnnotatedString(STUDIO_EMAIL)) + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + }, + modifier = Modifier.size(40.dp), + ) { + Icon( + Icons.Outlined.ContentCopy, + contentDescription = "Copy the studio address", + tint = NineLivesTheme.colors.goldFilament, + modifier = Modifier.size(18.dp), + ) + } } Text( - text = "Tap to write, long-press to copy. It is one person back here, " + + text = "Tap to write, or copy the address. It is one person back here, " + "so give me 72 hours before you assume I am ignoring you.", style = MaterialTheme.typography.bodySmall, color = NineLivesTheme.colors.archiveTextMuted, @@ -1923,7 +1945,7 @@ private fun FeedbackSection( Icon( imageVector = when (type) { SettingsViewModel.ReportType.BUG -> Icons.Outlined.BugReport - SettingsViewModel.ReportType.UPGRADE -> Icons.Outlined.RocketLaunch + SettingsViewModel.ReportType.FEATURE -> Icons.Outlined.RocketLaunch }, contentDescription = null, modifier = Modifier.size(16.dp), diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsViewModel.kt index 1168bb0..fae8753 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsViewModel.kt @@ -141,7 +141,7 @@ class SettingsViewModel @Inject constructor( enum class ReportType(val label: String, val subjectPrefix: String) { BUG("Bug Report", "[NineLives Bug]"), - UPGRADE("Upgrade Request", "[NineLives Request]"), + FEATURE("Feature Request", "[NineLives Request]"), } /** From a9dfa627a62749eea5dd05fef1ad09c9e4f42e15 Mon Sep 17 00:00:00 2001 From: Static Date: Thu, 20 Aug 2026 17:29:11 -0400 Subject: [PATCH 3/5] fix: drop the paid-era claim row from the Unlock section It read as clutter, and it was. A standing question about a price that no longer exists, shown forever to every free user, to serve a paid population of two. Jeff called it and he is right: the Unlock section now says one thing, that you can buy the unlock, plus Restore for people who already did. The channel survives, which is the only reason this is safe to cut. The one-time PaidEraClaimDialog still carries the claim, and the direct contact row further down the same screen is a general way in for anyone who dismissed the prompt and changed their mind. What is lost is a claim-SPECIFIC route for someone who tapped "No thanks", which makes the dialog's "this is the only time we'll ask" literally true rather than nearly true. That trade is fine here and would not be in general. It holds because the affected population is two people, one of whom is getting refunded anyway, and because a working contact row now exists to catch the third person who does not exist. If the contact row ever goes away, this decision has to be revisited, which is why the comment left in its place says so. Removing the row orphaned its wiring, so that went too rather than sitting there looking load-bearing: UnlockSettingsGroup's appVersion parameter, the argument at its call site, the LocalContext.current local, and the sendPaidEraClaimEmail import. The helper itself stays, still used by the dialog. Verified on device: Unlock section renders with only "Unlock Nine Lives" and the Restore line, no claim row in the tree. 423 tests, 0 failures, assembleDebug clean, no unused-symbol warnings. Evidence: /home/static/nine-lives-evidence/2026-08-20-settle-bound-and-contact-row/ Co-Authored-By: Claude Opus 5 --- .../app/ui/settings/SettingsScreen.kt | 56 ++++--------------- 1 file changed, 12 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsScreen.kt b/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsScreen.kt index fa6b82f..18c538c 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/settings/SettingsScreen.kt @@ -58,7 +58,6 @@ import com.ninelivesaudio.app.ui.copy.unhinged.CopyEngine import com.ninelivesaudio.app.ui.copy.unhinged.CopyStyleGuide import com.ninelivesaudio.app.ui.theme.NineLivesTheme import com.ninelivesaudio.app.ui.unlock.UnlockViewModel -import com.ninelivesaudio.app.ui.unlock.sendPaidEraClaimEmail import com.ninelivesaudio.app.ui.theme.unhinged.* /** @@ -551,10 +550,7 @@ fun SettingsScreen( // ═════════════════════════════════════════════════════════════ // Unlock // ═════════════════════════════════════════════════════════════ - UnlockSettingsGroup( - onNavigateToUnlock = onNavigateToUnlock, - appVersion = uiState.appVersion, - ) + UnlockSettingsGroup(onNavigateToUnlock = onNavigateToUnlock) SettingsGroup(title = "Experience") { ArchivePreferencesSection( @@ -969,12 +965,10 @@ private fun SkipSilenceRow( @Composable private fun UnlockSettingsGroup( onNavigateToUnlock: () -> Unit, - appVersion: String, viewModel: UnlockViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val restoreMessage by viewModel.restoreMessage.collectAsStateWithLifecycle() - val context = LocalContext.current SettingsGroup(title = "Unlock") { Row( @@ -1044,45 +1038,19 @@ private fun UnlockSettingsGroup( ) } - // The whole recovery path for anyone who bought the paid app. - // - // There is no automatic grandfather. Nothing shipped to Play ever wrote - // legacy_paid, and the date-gated writer built on 2026-08-20 was thrown - // away on purpose: it was only safe while a human remembered to flip the - // price after a compiled-in cutoff, and one slip would have marked every - // free install paid-for-life. See EntitlementPrefs. + // No paid-era claim row here on purpose. // - // So this row IS the mechanism, not a net under one. Play does not hand - // out buyer email addresses for a paid-app order, which means the app is - // the only channel that exists between a past buyer and us. If this row - // does not work, nothing does. + // There used to be one ("Bought this back when it cost money? Claim"), + // as the permanent path for anyone who dismissed the one-time prompt. + // It was removed on 2026-08-20 because it read as clutter: a standing + // question about a price that no longer exists, shown forever to every + // free user, to serve a paid population of two. // - // Hidden once unlocked, because there is nothing left to claim. - if (!uiState.isUnlocked) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Bought this back when it cost money?", - style = MaterialTheme.typography.bodySmall, - color = NineLivesTheme.colors.archiveTextMuted, - ) - Text( - text = "Claim", - style = MaterialTheme.typography.bodySmall.copy( - textDecoration = TextDecoration.Underline, - ), - color = NineLivesTheme.colors.goldFilament, - // Same helper the one-time prompt uses, so the two - // cannot drift into asking for different things. - modifier = Modifier.clickable { - sendPaidEraClaimEmail(context, appVersion) - }, - ) - } - } + // The channel survives. The one-time PaidEraClaimDialog still carries + // the claim, and the direct contact row further down this screen is a + // general way in for anyone who dismissed it and changed their mind. + // The dialog's "this is the only time we'll ask" is now literally true, + // which is why the contact row must keep working. } } From 89a5b56a424dc3497cad9cfe58d07d1aadf8b6e9 Mon Sep 17 00:00:00 2001 From: Static Date: Thu, 20 Aug 2026 17:46:51 -0400 Subject: [PATCH 4/5] docs: stop promising a Settings row that no longer exists Removing the claim row left five comments describing it as a live safety net. Two of them were not merely stale, they were load-bearing and wrong: PaidEraClaimPolicy's firstInstallTime param said reinstall "is what the Settings row covers", and its error-path note said "the Settings row still covers anyone that misses". Both promised a net that was deleted hours earlier. A reader deciding whether the silent error path is acceptable would have weighed it against a fallback that is not there. They now say what is actually true: a reinstall or a failed lookup means the buyer has to write in through the direct-contact row, and the trade still holds because the alternative is prompting EVERY install on the error path. The reasoning survives, the false comfort does not. The other three were history rather than danger and are reworded to read as history: the "why a prompt and not just the Settings row" section, the shared-email-builder note, and the NEW_TASK explanation, which is clearer now that it says there were once two callers and only one of them crashed. Left alone deliberately: MainActivity, InAppReviewManager and ReviewEligibility all mention "a Settings row" too, but they mean the Rate Nine Lives row, which still exists. Codex reviewed the branch against master before this and returned no findings, so this one is on the surface pass, not the review. Canary run this pass, both directions, because a gate never seen to fail is unproven: forcing shouldPrompt false turned the two buyer-is-offered tests red, forcing it true turned all four guard tests red. Reverted and the file confirmed restored. 423 tests, 0 failures, assembleDebug clean. Co-Authored-By: Claude Opus 5 --- .../app/entitlement/PaidEraClaimPolicy.kt | 29 +++++++++++++------ .../app/ui/unlock/PaidEraClaim.kt | 14 +++++---- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/entitlement/PaidEraClaimPolicy.kt b/app/src/main/java/com/ninelivesaudio/app/entitlement/PaidEraClaimPolicy.kt index cd0baa0..71649e6 100644 --- a/app/src/main/java/com/ninelivesaudio/app/entitlement/PaidEraClaimPolicy.kt +++ b/app/src/main/java/com/ninelivesaudio/app/entitlement/PaidEraClaimPolicy.kt @@ -22,12 +22,18 @@ package com.ninelivesaudio.app.entitlement * have no other way to find out the offer exists. So [PROMPT_CUTOFF_MILLIS] is * set generously past the expected flip rather than tightly against it. * - * ## Why a prompt and not just the Settings row + * ## Why a prompt, and why it is now the only claim-specific path * - * The Settings row is the permanent path, but nobody scrolls into Settings - * hunting for a refund they do not know exists. Play does not hand out buyer - * email addresses for a paid-app order, so the app is the only channel between - * a past buyer and us, and a channel nobody opens is not a channel. + * There was briefly a permanent Settings row too. It was removed on 2026-08-20: + * a standing question about a price that no longer exists, shown forever to + * every free user, to serve a paid population of two. Nobody scrolls into + * Settings hunting for a refund they do not know exists anyway. + * + * So the prompt is the claim. Play does not hand out buyer email addresses for + * a paid-app order, so the app is the only channel between a past buyer and us, + * and a channel nobody opens is not a channel. Anyone who dismisses the prompt + * falls back to the general direct-contact row in Settings, which is why that + * row is load-bearing and must not be removed without revisiting this. */ object PaidEraClaimPolicy { @@ -45,8 +51,10 @@ object PaidEraClaimPolicy { /** * @param firstInstallTimeMillis `PackageInfo.firstInstallTime`. Survives - * updates, so a buyer who updates late still gets the prompt. Does not - * survive uninstall and reinstall, which is what the Settings row covers. + * updates, so a buyer who updates late still gets the prompt. Does NOT + * survive uninstall and reinstall, and since the Settings claim row was + * removed nothing in the app catches that case. A buyer who reinstalls has + * to write in through the direct-contact row instead. * @param isUnlocked already entitled, by purchase or by a restored flag, so * there is nothing to claim. * @param alreadyPrompted the prompt has been shown once. Once is the whole @@ -55,8 +63,11 @@ object PaidEraClaimPolicy { * * A non-positive `firstInstallTimeMillis` means the lookup failed. Treated * as "do not prompt", so a broken read is silent rather than showing a - * confusing dialog to every install on the error path. The Settings row - * still covers anyone that misses. + * confusing dialog to every install on the error path. Nothing else catches + * that case now, so the miss is real: the buyer would have to write in. That + * trade still holds, because the error path would otherwise prompt EVERY + * install, and annoying everyone to catch a lookup failure that may never + * happen is the worse side of the bet. */ fun shouldPrompt( firstInstallTimeMillis: Long, diff --git a/app/src/main/java/com/ninelivesaudio/app/ui/unlock/PaidEraClaim.kt b/app/src/main/java/com/ninelivesaudio/app/ui/unlock/PaidEraClaim.kt index d51b853..551c356 100644 --- a/app/src/main/java/com/ninelivesaudio/app/ui/unlock/PaidEraClaim.kt +++ b/app/src/main/java/com/ninelivesaudio/app/ui/unlock/PaidEraClaim.kt @@ -38,8 +38,10 @@ private const val CLAIM_SUBJECT = "Nine Lives: paid-app unlock claim" /** * Open a mail client with the paid-era claim prefilled. * - * Shared by the Settings row and the one-time prompt so the two cannot drift - * into asking for different things. Falls back to a chooser when nothing + * Used by the one-time prompt. It was shared with a Settings claim row until + * that row was removed on 2026-08-20, and it stays a named function rather than + * being inlined because the claim copy belongs in exactly one place regardless + * of how many callers there are. Falls back to a chooser when nothing * handles `mailto:`, because a dead button here means a past buyer has no way * to reach us at all: Play does not expose buyer email addresses for a * paid-app order, so this really is the only channel. @@ -82,9 +84,11 @@ fun sendPaidEraClaimEmail(context: Context, appVersion: String) { * Add FLAG_ACTIVITY_NEW_TASK when the context is not an Activity. * * Caught on a real device, and it would never have shown up in a unit test. - * The Settings row passes an Activity context and worked. The dialog routed - * through a ViewModel holding the application context, and ContextImpl throws - * outright rather than degrading. Applied conditionally rather than always, + * At the time there were two callers: a Settings claim row (since removed) which + * passed an Activity context and worked, and this dialog, which routed through a + * ViewModel holding the application context. ContextImpl throws outright rather + * than degrading, so only one of the two crashed and they looked identical from + * the source. Applied conditionally rather than always, * because forcing a new task from an Activity changes the back stack the mail * client comes back to. */ From dffcbc6f32e5969a17ad91878e0c0135fec3cb43 Mon Sep 17 00:00:00 2001 From: Static Date: Thu, 20 Aug 2026 18:33:35 -0400 Subject: [PATCH 5/5] fix: settle the purchase gate on an answer, not on any exit Codex caught this on a second review pass and it is real, though its stated mechanism is not. It assumed a foreground refresh can run before Billing setup completes. That cannot happen here: a failed setup returns before refreshPurchases is ever called, which is also why the gate already stays false forever on a device with no Play Store. The path that does exist is narrower. Setup succeeds, then queryPurchasesAsync fails transiently, and the old `finally` settled the gate anyway. The claim prompt then treats a provisional free reading as established, and an unlock owner mid-reinstall can be offered a free code for the thing they already bought. They can tap through to the email before a later refresh corrects it. So the gate now settles on an ANSWER rather than on any exit. The distinction that matters is between Play saying "no" and Play saying nothing: - OK settles it. - A hard no (BILLING_UNAVAILABLE, DEVELOPER_ERROR) settles it, because a retry says the same thing and waiting only delays a correct decision. - SERVICE_DISCONNECTED, SERVICE_UNAVAILABLE and NETWORK_ERROR do NOT, because auto-reconnection can plausibly fix those within seconds and the consumer's own bound is longer than that. - A 30s timeout still settles it. Play had the whole window. This does not make anything correct that was incorrect. It buys a retry window inside a bound that already existed, which is the entire value: hold it slightly longer and you get the real answer instead of a guess. Entitlement application is untouched, so the tested asymmetry in PurchaseEvaluator (only a SUCCESSFUL query may revoke) still holds exactly as before. The only thing that changed is WHEN the gate flips. CANARY on device, both directions, because this makes the gate harder to settle and the failure mode of that is a prompt that silently never comes: forcing queryAndApply to never answer suppressed the prompt at t+5s, +10s, +15s and +20s, then the ViewModel's 35s backstop released it at t+25s. Proves the gate gates AND that the backstop still prevents a hang. Reverted, zero markers left, prompt back at t+5s. Also corrects an overstatement in the previous commit's comment. It said the unsettled timeout path left waiters "waiting forever". The only consumer has always carried its own bound, so it was seconds, not forever. The fix was still right, because purchaseQuerySettled is public and a future consumer without a bound WOULD hang, and the doc now says that as a requirement on consumers rather than a promise from this flow. 423 tests, 0 failures, assembleDebug clean. Evidence: /home/static/nine-lives-evidence/2026-08-20-settle-bound-and-contact-row/ Co-Authored-By: Claude Opus 5 --- .../app/entitlement/BillingManager.kt | 77 +++++++++++++++---- 1 file changed, 60 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/ninelivesaudio/app/entitlement/BillingManager.kt b/app/src/main/java/com/ninelivesaudio/app/entitlement/BillingManager.kt index 1489fc6..a1249f4 100644 --- a/app/src/main/java/com/ninelivesaudio/app/entitlement/BillingManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/entitlement/BillingManager.kt @@ -96,7 +96,7 @@ class BillingManager @Inject constructor( private val _purchaseQuerySettled = MutableStateFlow(false) /** - * True once the first purchase query has finished, however it finished. + * True once the first purchase query has produced an ANSWER. * * Same ambiguity as [productLookupSettled], one step more dangerous. Before * the first query answers, entitlement reads as free for everybody, because @@ -104,10 +104,15 @@ class BillingManager @Inject constructor( * survive a reinstall or a device move. Anything that acts on "user is free" * during that window acts on a value that has not been established yet. * - * Set in [refreshPurchases]'s `finally`, which is the one place cancellation - * cannot skip. A consumer waiting on this must not be left waiting forever by - * a device with no Play Store, or by a query that Play never answers, both of - * which are legitimate states rather than errors. + * "Answer" excludes retryable transport failures. A disconnected service or a + * dead network is Play saying NOTHING, not Play saying "you own nothing", and + * flipping this on one hands a consumer a provisional free reading dressed up + * as an established one. See [RETRYABLE_RESPONSE_CODES]. + * + * This flow can therefore stay false forever, and that is deliberate: a device + * with no Play Store never completes setup, so [refreshPurchases] never runs + * there at all. Every consumer MUST carry its own bound rather than awaiting + * this indefinitely. */ val purchaseQuerySettled: StateFlow = _purchaseQuerySettled.asStateFlow() @@ -183,6 +188,7 @@ class BillingManager @Inject constructor( Log.d(TAG, "refresh already in flight, skipping") return } + var answered = false try { // Bounded on purpose. The Billing KTX helpers suspend until Play // invokes their callback, and nothing guarantees it ever does. Without @@ -192,20 +198,37 @@ class BillingManager @Inject constructor( // // A timeout is not a revocation. It produces no verdict at all, which // is the same thing a failed query does. - withTimeoutOrNull(BILLING_TIMEOUT_MS) { queryAndApply() } - ?: Log.d(TAG, "purchase query timed out, leaving entitlement untouched") + answered = withTimeoutOrNull(BILLING_TIMEOUT_MS) { queryAndApply() } ?: run { + Log.d(TAG, "purchase query timed out, leaving entitlement untouched") + // A timeout DOES settle the gate. Play had the full window and + // produced nothing, so waiting past it buys a consumer nothing + // except a longer stare at a spinner. + true + } } finally { - // Settled HERE and only here. An earlier version set this at the - // exits inside queryAndApply, which withTimeoutOrNull cancels before - // they run, so the 30s timeout path never settled and anything - // waiting on it waited forever. A timeout still produces no verdict, - // but "Play did not answer" is itself the answer a waiter needs. - _purchaseQuerySettled.value = true + // Settled out here rather than at the exits inside queryAndApply, + // which withTimeoutOrNull cancels before they run, so the timeout path + // never settled at all. + // + // Conditionally, though. Settling unconditionally was wrong for the + // reason a second review pass caught: a retryable transport failure is + // Play saying nothing, and treating it as an answer lets the paid-era + // claim prompt act on a provisional free reading. An unlock owner + // mid-reinstall could then be offered a free code for the thing they + // already bought. Leaving it unsettled gives auto-reconnection a + // window to land the real answer first. + if (answered) _purchaseQuerySettled.value = true refreshMutex.unlock() } } - private suspend fun queryAndApply() { + /** + * @return whether Play produced an answer, which is a strictly weaker claim + * than the query succeeding. A hard "no" (billing unavailable, developer + * error) IS an answer and settles the gate, because a retry will say the + * same thing. Only the retryable transport codes return false. + */ + private suspend fun queryAndApply(): Boolean { val params = QueryPurchasesParams.newBuilder() .setProductType(BillingClient.ProductType.INAPP) .build() @@ -213,11 +236,11 @@ class BillingManager @Inject constructor( val result = billingCall { client.queryPurchasesAsync(params) } if (result == null) { Log.d(TAG, "purchase query threw, leaving entitlement untouched") - return + return false } - val responseOk = - result.billingResult.responseCode == BillingClient.BillingResponseCode.OK + val responseCode = result.billingResult.responseCode + val responseOk = responseCode == BillingClient.BillingResponseCode.OK val snapshots = result.purchasesList.flatMap { it.toSnapshots() } when (PurchaseEvaluator.evaluateQuery(responseOk, snapshots)) { @@ -231,6 +254,12 @@ class BillingManager @Inject constructor( // days, and a missed callback would otherwise cost the user their money // and us the sale. if (responseOk) acknowledgeIfNeeded(result.purchasesList) + + if (!responseOk && responseCode in RETRYABLE_RESPONSE_CODES) { + Log.d(TAG, "purchase query not answered ($responseCode), gate stays open") + return false + } + return true } /** Load `nine_lives_unlock` so the unlock screen can show a real price. */ @@ -348,6 +377,20 @@ class BillingManager @Inject constructor( */ const val BILLING_TIMEOUT_MS = 30_000L + /** + * Codes where Play said NOTHING, as opposed to saying "no". + * + * These are the ones auto-reconnection can plausibly fix on its own + * within seconds, so they must not settle [purchaseQuerySettled]. + * Everything else, including BILLING_UNAVAILABLE and DEVELOPER_ERROR, + * is a stable answer that a retry would only repeat. + */ + val RETRYABLE_RESPONSE_CODES = setOf( + BillingClient.BillingResponseCode.SERVICE_DISCONNECTED, + BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE, + BillingClient.BillingResponseCode.NETWORK_ERROR, + ) + /** * One Play purchase can carry several product ids, so flatten rather * than assuming index zero.