Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/src/main/java/com/ninelivesaudio/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,29 @@ class BillingManager @Inject constructor(
*/
val productLookupSettled: StateFlow<Boolean> = _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<Boolean> = _purchaseQuerySettled.asStateFlow()

private val client: BillingClient = BillingClient.newBuilder(context)
.setListener(this)
// One-time products can go PENDING (cash payments, parental approval).
Expand Down Expand Up @@ -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
Expand All @@ -174,26 +198,49 @@ 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()

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)) {
Expand All @@ -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. */
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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 `<include>` 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"
}
}
Loading
Loading