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