From 125bd5f3d43af8f12b47a699b6ad81e4ac028d24 Mon Sep 17 00:00:00 2001 From: Static Date: Fri, 21 Aug 2026 20:23:00 -0400 Subject: [PATCH] chore: remove the paid-era claim path The claim path existed to catch people who bought Nine Lives while it cost money. That population is one person, and they were refunded on 2026-08-21. A refund unwinds the transaction. They stand where any free user stands, and if they want the unlock it costs what it costs everyone. So this deletes the machinery built for them, and it was more machinery than the problem deserved: - the one-time "You paid for this" dialog and its email helper - PaidEraClaimPolicy, PaidEraClaimPrefs, and their tests - the compiled-in PROMPT_CUTOFF_MILLIS date - purchaseQuerySettled, the gate that existed only to keep that dialog from acting on a provisional reading of what a user owns - PurchaseGatePolicy and its tests, which existed only to classify for that gate WHY IT HAD TO GO, not just why it was unnecessary. The app cannot tell who paid, because Play does not expose buyer identity for a paid-app order. The prompt guessed from firstInstallTime against a cutoff date. That guess told every pre-cutoff install "You paid for this" and invited them to email for a free code. The Play reviewer installs fresh during review. With any cutoff later than the submission date, the reviewer qualifies, and the build gating the relaunch shows the reviewer a false claim plus an off-platform offer. Making the date safe meant compiling in a value that had to stay ahead of a submission date nobody knows at build time, and keeping it correct forever after. That is a lot of standing risk to carry for somebody who has their money back. WHAT SURVIVES. The direct contact row in Settings, which is the whole channel now and is enough. Anyone who believes they bought this writes in and gets answered by hand with a promo code. It scales to the population it serves, and it cannot lie to anyone because a human reads it first. Reactive for one person beats machinery on every install. Same call Jeff made on 2026-08-20 when a refund beat a date-gated grandfather writer, applied one level up. Comments that described the removed pieces are rewritten rather than left to contradict the code, including the reasoning above, so nobody rebuilds this without meeting the argument against it first. 416 tests, 0 failures. assembleDebug clean. Co-Authored-By: Claude Opus 5 --- .../com/ninelivesaudio/app/MainActivity.kt | 9 - .../app/entitlement/BillingManager.kt | 62 +---- .../app/entitlement/EntitlementPrefs.kt | 40 +-- .../app/entitlement/PaidEraClaimPolicy.kt | 81 ------ .../app/entitlement/PaidEraClaimPrefs.kt | 55 ---- .../app/entitlement/PurchaseGatePolicy.kt | 41 --- .../app/ui/settings/SettingsScreen.kt | 27 +- .../app/ui/unlock/PaidEraClaim.kt | 235 ------------------ .../app/entitlement/PaidEraClaimPolicyTest.kt | 98 -------- .../app/entitlement/PurchaseGatePolicyTest.kt | 67 ----- 10 files changed, 40 insertions(+), 675 deletions(-) delete mode 100644 app/src/main/java/com/ninelivesaudio/app/entitlement/PaidEraClaimPolicy.kt delete mode 100644 app/src/main/java/com/ninelivesaudio/app/entitlement/PaidEraClaimPrefs.kt delete mode 100644 app/src/main/java/com/ninelivesaudio/app/entitlement/PurchaseGatePolicy.kt delete mode 100644 app/src/main/java/com/ninelivesaudio/app/ui/unlock/PaidEraClaim.kt delete mode 100644 app/src/test/java/com/ninelivesaudio/app/entitlement/PaidEraClaimPolicyTest.kt delete mode 100644 app/src/test/java/com/ninelivesaudio/app/entitlement/PurchaseGatePolicyTest.kt diff --git a/app/src/main/java/com/ninelivesaudio/app/MainActivity.kt b/app/src/main/java/com/ninelivesaudio/app/MainActivity.kt index 7c94bc2..f6229aa 100644 --- a/app/src/main/java/com/ninelivesaudio/app/MainActivity.kt +++ b/app/src/main/java/com/ninelivesaudio/app/MainActivity.kt @@ -55,7 +55,6 @@ 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 @@ -231,14 +230,6 @@ 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 ff53fa2..aafbd09 100644 --- a/app/src/main/java/com/ninelivesaudio/app/entitlement/BillingManager.kt +++ b/app/src/main/java/com/ninelivesaudio/app/entitlement/BillingManager.kt @@ -93,28 +93,6 @@ class BillingManager @Inject constructor( */ val productLookupSettled: StateFlow = _productLookupSettled.asStateFlow() - private val _purchaseQuerySettled = MutableStateFlow(false) - - /** - * 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 - * 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. - * - * "Answer" excludes retryable failures. A disconnected service, a dead - * network, or Play's generic ERROR 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 [PurchaseGatePolicy]. - * - * 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() private val client: BillingClient = BillingClient.newBuilder(context) .setListener(this) @@ -188,7 +166,6 @@ 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 @@ -198,37 +175,14 @@ 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. - 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 - } + withTimeoutOrNull(BILLING_TIMEOUT_MS) { queryAndApply() } + ?: Log.d(TAG, "purchase query timed out, leaving entitlement untouched") } finally { - // 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() } } - /** - * @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 { + private suspend fun queryAndApply() { val params = QueryPurchasesParams.newBuilder() .setProductType(BillingClient.ProductType.INAPP) .build() @@ -236,7 +190,7 @@ class BillingManager @Inject constructor( val result = billingCall { client.queryPurchasesAsync(params) } if (result == null) { Log.d(TAG, "purchase query threw, leaving entitlement untouched") - return false + return } val responseCode = result.billingResult.responseCode @@ -254,14 +208,6 @@ 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) - - // Classification lives in PurchaseGatePolicy so it can be tested without - // a Billing client. See PurchaseGatePolicyTest. - if (!responseOk && !PurchaseGatePolicy.settles(responseCode)) { - 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. */ 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 3875dd5..a75187b 100644 --- a/app/src/main/java/com/ninelivesaudio/app/entitlement/EntitlementPrefs.kt +++ b/app/src/main/java/com/ninelivesaudio/app/entitlement/EntitlementPrefs.kt @@ -33,29 +33,29 @@ import javax.inject.Singleton * The flag can now only arrive from an Auto Backup restore of an install that * predates the switch. Everyone else is recovered by hand. * - * ## The paid population is no longer just Jeff + * ## The paid population is zero, and that is now literally true + + * CORRECTED 2026-08-21. A stranger bought the paid app on 2026-08-16, which + * briefly made the population two rather than one. Jeff refunded them on + * 2026-08-21, so the transaction is unwound and they stand exactly where any + * free user stands. * - * 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. + * Two mechanisms were built for that one person and both were thrown away. A + * date-gated writer went first: it worked, but stayed safe only while a human + * remembered to flip the price after a compiled-in cutoff, and one forgotten + * ordering rule would have grandfathered every free install. A one-time claim + * prompt went second: it could not tell who had paid, so it guessed from + * install date and told every pre-cutoff install "You paid for this", the Play + * reviewer included. * - * 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. + * What survives is the direct contact row in Settings. Anyone who believes they + * bought this writes in and gets answered by hand with a promo code for + * `nine_lives_unlock`. That scales to the population it has to serve, which is + * zero, and it cannot lie to anybody because a human reads it first. * - * 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. + * If real paid volume ever appears in the order history before the flip, + * revisit this. 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 deleted file mode 100644 index 71649e6..0000000 --- a/app/src/main/java/com/ninelivesaudio/app/entitlement/PaidEraClaimPolicy.kt +++ /dev/null @@ -1,81 +0,0 @@ -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 why it is now the only claim-specific path - * - * 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 { - - /** - * 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, 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 - * 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. 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, - 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 deleted file mode 100644 index 777c3a0..0000000 --- a/app/src/main/java/com/ninelivesaudio/app/entitlement/PaidEraClaimPrefs.kt +++ /dev/null @@ -1,55 +0,0 @@ -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/entitlement/PurchaseGatePolicy.kt b/app/src/main/java/com/ninelivesaudio/app/entitlement/PurchaseGatePolicy.kt deleted file mode 100644 index e61a344..0000000 --- a/app/src/main/java/com/ninelivesaudio/app/entitlement/PurchaseGatePolicy.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.ninelivesaudio.app.entitlement - -/** - * Decides whether a `queryPurchasesAsync` response is a final answer from Play. - * - * Deliberately holds no Billing types, for the same reason [PurchaseEvaluator] - * does not: the classification is the part that must be exhaustively tested, - * and it should not need a Billing client, a Play Store, or a network to run. - * `PurchaseGatePolicyTest` pins the mirrored values against the real library so - * they cannot drift silently. - */ -object PurchaseGatePolicy { - - const val SERVICE_DISCONNECTED = -1 - const val SERVICE_UNAVAILABLE = 2 - const val NETWORK_ERROR = 12 - const val ERROR = 6 - - /** - * Codes where Play said NOTHING, as opposed to saying "no". - * - * These are the ones auto-reconnection or a plain retry can plausibly turn - * into a real answer within seconds, so they must not settle the gate. - * Everything else, including BILLING_UNAVAILABLE and DEVELOPER_ERROR, is a - * stable answer that a retry would only repeat. - */ - private val RETRYABLE = setOf( - SERVICE_DISCONNECTED, - SERVICE_UNAVAILABLE, - NETWORK_ERROR, - // ERROR is Play's generic "something went wrong on our side", which - // Google documents as retryable. It reads like a hard failure and is - // not one. Omitting it is what let a reinstalled unlock owner, whose - // local Play cache is empty, get offered a free code for the unlock - // they already bought. - ERROR, - ) - - /** True when this response is an answer the gate may settle on. */ - fun settles(responseCode: Int): Boolean = responseCode !in RETRYABLE -} 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 18c538c..7f65485 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 @@ -1038,19 +1038,24 @@ private fun UnlockSettingsGroup( ) } - // No paid-era claim row here on purpose. + // No paid-era claim row here, and no claim prompt anywhere either. // - // 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. + // The whole claim path was removed on 2026-08-21. A standing row went + // first, on 2026-08-20, for reading as clutter. Then the one-time dialog + // went too, once the only person it existed for had been refunded. // - // 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. + // The reasoning, so nobody rebuilds it: the prompt could not tell who + // had actually paid, because Play does not expose buyer identity for a + // paid-app order. It guessed from install date. That guess told every + // pre-cutoff install "You paid for this" and offered them a code, + // including the Play reviewer, who installs fresh during review. All of + // that to serve one person who has their money back. + // + // The direct contact row further down this screen is now the entire + // channel, and it is enough: anyone who writes in gets answered by hand + // with a promo code. Reactive for one person beats machinery on every + // install. If real paid volume ever appears before the flip, revisit + // this, but build for who exists rather than who might. } } 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 deleted file mode 100644 index 551c356..0000000 --- a/app/src/main/java/com/ninelivesaudio/app/ui/unlock/PaidEraClaim.kt +++ /dev/null @@ -1,235 +0,0 @@ -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. - * - * 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. - */ -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. - * 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. - */ -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 deleted file mode 100644 index 6a367b7..0000000 --- a/app/src/test/java/com/ninelivesaudio/app/entitlement/PaidEraClaimPolicyTest.kt +++ /dev/null @@ -1,98 +0,0 @@ -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" - } -} diff --git a/app/src/test/java/com/ninelivesaudio/app/entitlement/PurchaseGatePolicyTest.kt b/app/src/test/java/com/ninelivesaudio/app/entitlement/PurchaseGatePolicyTest.kt deleted file mode 100644 index ad05478..0000000 --- a/app/src/test/java/com/ninelivesaudio/app/entitlement/PurchaseGatePolicyTest.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.ninelivesaudio.app.entitlement - -import com.android.billingclient.api.BillingClient.BillingResponseCode -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * The settle gate decides WHEN the claim prompt is allowed to trust its reading - * of a user's purchases. Settling early on a code that a retry could have - * answered means offering a free unlock code to somebody who already bought - * one, which costs them a confusing dialog and us a support email. - * - * This set had no test before 2026-08-21, which is how ERROR stayed missing - * from it through three review rounds. - */ -class PurchaseGatePolicyTest { - - @Test - fun `a Play shrug does not settle the gate`() { - // Codes where Play said nothing usable and a retry can plausibly fix it. - assertFalse(PurchaseGatePolicy.settles(BillingResponseCode.SERVICE_DISCONNECTED)) - assertFalse(PurchaseGatePolicy.settles(BillingResponseCode.SERVICE_UNAVAILABLE)) - assertFalse(PurchaseGatePolicy.settles(BillingResponseCode.NETWORK_ERROR)) - assertFalse(PurchaseGatePolicy.settles(BillingResponseCode.ERROR)) - } - - @Test - fun `a stable answer settles the gate`() { - // A retry would only repeat these, so waiting only delays a correct call. - assertTrue(PurchaseGatePolicy.settles(BillingResponseCode.OK)) - assertTrue(PurchaseGatePolicy.settles(BillingResponseCode.BILLING_UNAVAILABLE)) - assertTrue(PurchaseGatePolicy.settles(BillingResponseCode.DEVELOPER_ERROR)) - assertTrue(PurchaseGatePolicy.settles(BillingResponseCode.FEATURE_NOT_SUPPORTED)) - assertTrue(PurchaseGatePolicy.settles(BillingResponseCode.ITEM_UNAVAILABLE)) - } - - /** - * ERROR is the regression this test exists for. - * - * A reinstalled unlock owner has no local Play cache. If that first - * queryPurchasesAsync returns a transient ERROR and the gate settles on it, - * the claim dialog reads "free" as established and offers them a code for - * the unlock they already own. Google documents ERROR as retryable. - */ - @Test - fun `transient ERROR is treated as retryable, not as an answer`() { - assertFalse( - "ERROR is a Play shrug. Settling on it offers a paid user a free code.", - PurchaseGatePolicy.settles(BillingResponseCode.ERROR), - ) - } - - /** - * Guards against the pure layer drifting from the Billing library it - * mirrors. If Google renumbers a code, this fails rather than silently - * reclassifying it. - */ - @Test - fun `mirrored codes still match the Billing library`() { - assertEquals(BillingResponseCode.SERVICE_DISCONNECTED, PurchaseGatePolicy.SERVICE_DISCONNECTED) - assertEquals(BillingResponseCode.SERVICE_UNAVAILABLE, PurchaseGatePolicy.SERVICE_UNAVAILABLE) - assertEquals(BillingResponseCode.NETWORK_ERROR, PurchaseGatePolicy.NETWORK_ERROR) - assertEquals(BillingResponseCode.ERROR, PurchaseGatePolicy.ERROR) - } -}