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
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,10 @@ class BillingManager @Inject constructor(
* 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].
* "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
Expand Down Expand Up @@ -255,7 +255,9 @@ class BillingManager @Inject constructor(
// and us the sale.
if (responseOk) acknowledgeIfNeeded(result.purchasesList)

if (!responseOk && responseCode in RETRYABLE_RESPONSE_CODES) {
// 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
}
Expand Down Expand Up @@ -377,20 +379,6 @@ 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
@@ -0,0 +1,41 @@
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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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)
}
}
Loading