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..a1249f4 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,29 @@ 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 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() + private val client: BillingClient = BillingClient.newBuilder(context) .setListener(this) // One-time products can go PENDING (cash payments, parental approval). @@ -165,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 @@ -174,14 +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 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() @@ -189,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)) { @@ -207,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. */ @@ -324,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. 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..71649e6 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/entitlement/PaidEraClaimPolicy.kt @@ -0,0 +1,81 @@ +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 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..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 @@ -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 @@ -856,7 +861,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 +870,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 +880,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 @@ -1020,6 +1037,20 @@ private fun UnlockSettingsGroup( modifier = Modifier.clickable { viewModel.restorePurchases() }, ) } + + // No paid-era claim row here on purpose. + // + // 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 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. } } @@ -1605,6 +1636,81 @@ 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)) + .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), + ) { + Text( + text = "Something not covered here?", + style = MaterialTheme.typography.bodyMedium, + color = NineLivesTheme.colors.archiveTextPrimary, + ) + // 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, 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, + ) + } +} + // ─── Section Label (inside a group) ────────────────────────────────────── @Composable @@ -1807,7 +1913,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]"), } /** 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..551c356 --- /dev/null +++ b/app/src/main/java/com/ninelivesaudio/app/ui/unlock/PaidEraClaim.kt @@ -0,0 +1,235 @@ +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 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" + } +}