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: 0 additions & 9 deletions app/src/main/java/com/ninelivesaudio/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,28 +93,6 @@ 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 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<Boolean> = _purchaseQuerySettled.asStateFlow()

private val client: BillingClient = BillingClient.newBuilder(context)
.setListener(this)
Expand Down Expand Up @@ -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
Expand All @@ -198,45 +175,22 @@ 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()

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
Expand All @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down

This file was deleted.

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
}

Expand Down
Loading
Loading