fix(upgrade): Cut report noise, pace retries and fix trial wording

This commit is contained in:
Matthias Urhahn
2026-07-11 21:32:06 +02:00
committed by GitHub
parent bd36c5e5d6
commit c8179c5c19
5 changed files with 115 additions and 6 deletions
@@ -10,7 +10,8 @@ internal val BillingResult.isGplayUnavailableTemporary: Boolean
get() = setOf(
BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE,
BillingClient.BillingResponseCode.SERVICE_DISCONNECTED,
BillingClient.BillingResponseCode.SERVICE_TIMEOUT
BillingClient.BillingResponseCode.SERVICE_TIMEOUT,
BillingClient.BillingResponseCode.NETWORK_ERROR,
).contains(responseCode)
internal val BillingResult.isGplayUnavailablePermanent: Boolean
@@ -27,7 +27,7 @@ import javax.inject.Singleton
class BillingDataRepo @Inject constructor(
billingClientConnectionProvider: BillingClientConnectionProvider,
@AppScope private val scope: CoroutineScope,
appForegroundState: AppForegroundState,
private val appForegroundState: AppForegroundState,
private val timeSource: TimeSource,
) {
@@ -39,7 +39,17 @@ class BillingDataRepo @Inject constructor(
.retryWhen { cause, attempt ->
if (cause is CancellationException) return@retryWhen false
log(TAG, ERROR) { "Unable to provide client connection (attempt=$attempt):\n${cause.asLog()}" }
delay(60_000) // 60s between retries (upstream already did 5 quick retries)
// Capped backoff: don't hammer a persistently broken Play from the always-hot process
// (upstream already did 5 quick retries). A foreground *entry* short-circuits the
// wait — e.g. the user just returned from signing into the missing Google account
// and shouldn't have to wait out the full backoff.
val backoffMs = (RETRY_BACKOFF_BASE_MS * (attempt + 1)).coerceAtMost(RETRY_BACKOFF_MAX_MS)
// StateFlow dedupes, so after dropping the current value the next `true` is a real
// foreground *entry*, not the pre-existing foreground state.
val kicked = withTimeoutOrNull(backoffMs) {
appForegroundState.isForeground.drop(1).first { it }
}
if (kicked != null) log(TAG) { "App came to foreground, retrying billing connection early" }
true
}
.replayingShare(scope)
@@ -190,15 +200,25 @@ class BillingDataRepo @Inject constructor(
private const val FOREGROUND_REFRESH_THROTTLE_MS = 60 * 60 * 1000L // 1h
private const val FOREGROUND_REFRESH_TIMEOUT_MS = 30_000L
private const val RETRY_BACKOFF_BASE_MS = 60_000L
private const val RETRY_BACKOFF_MAX_MS = 5 * 60_000L
// Expected environmental/user situations — user-facing handling only, no bug report.
// USER_CANCELED stays silent in the UI, ITEM_ALREADY_OWNED is auto-handled by
// UpgradeRepoGplay (restore instead of error).
private val IGNORED_LAUNCH_CODES = setOf(
// UpgradeRepoGplay (restore instead of error), the service/network codes are transient
// connectivity states the user sees a proper error dialog for. Actionable codes
// (DEVELOPER_ERROR, ITEM_UNAVAILABLE, unknown future codes) keep reporting.
@Suppress("DEPRECATION")
internal val IGNORED_LAUNCH_CODES = setOf(
BillingClient.BillingResponseCode.USER_CANCELED,
BillingClient.BillingResponseCode.BILLING_UNAVAILABLE,
BillingClient.BillingResponseCode.ERROR,
BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED,
BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE,
BillingClient.BillingResponseCode.SERVICE_DISCONNECTED,
BillingClient.BillingResponseCode.SERVICE_TIMEOUT,
BillingClient.BillingResponseCode.NETWORK_ERROR,
BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED,
)
internal fun Throwable.tryMapUserFriendly(): Throwable = when {
@@ -418,7 +418,12 @@ private fun PricingContent(
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.upgrade_screen_options_description),
// Don't promise a free trial when Play didn't return the trial offer (e.g. returning
// subscribers) — the subscribe button already falls back accordingly.
text = stringResource(
if (state.hasTrialOffer) R.string.upgrade_screen_options_description
else R.string.upgrade_screen_options_description_no_trial
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
@@ -462,6 +467,23 @@ private fun UpgradeScreenPreview() = PreviewWrapper {
)
}
@Preview2
@Composable
private fun UpgradeScreenNoTrialPreview() = PreviewWrapper {
UpgradeScreen(
state = UpgradeViewModel.Pricing(
subPrice = "€3.49",
iapPrice = "€6.49",
hasTrialOffer = false,
),
onNavigateUp = {},
onSubscription = {},
onSubscriptionTrial = {},
onIap = {},
onRestore = {},
)
}
@Preview2
@Composable
private fun UpgradeScreenReturningBuyerPreview() = PreviewWrapper {
+1
View File
@@ -32,6 +32,7 @@
<string name="upgrade_benefit_disclaimer">Feature availability depends on your headphones and device.</string>
<string name="upgrade_screen_options_description">Same features, different pricing. The subscription includes a free trial and can be cancelled at any time.</string>
<string name="upgrade_screen_options_description_no_trial">Same features, different pricing. The subscription can be cancelled at any time.</string>
<string name="upgrade_screen_subscription_trial_action">Start free trial</string>
<string name="upgrade_screen_subscription_action">Subscribe yearly</string>
@@ -20,6 +20,7 @@ import io.mockk.mockk
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@@ -64,6 +65,70 @@ class BillingDataRepoTest : BaseTest() {
mapped.shouldBeInstanceOf<GplayServiceUnavailableException>()
}
@Test
fun `network error maps to GplayServiceUnavailableException`() {
val result = mockBillingResult(BillingClient.BillingResponseCode.NETWORK_ERROR)
val mapped = BillingResultException(result).tryMapUserFriendly()
mapped.shouldBeInstanceOf<GplayServiceUnavailableException>()
}
@Test
@Suppress("DEPRECATION")
fun `transient launch failures are not bug-reported, actionable ones are`() {
with(BillingDataRepo.IGNORED_LAUNCH_CODES) {
// Expected user/environmental situations — the user already sees proper UI for these.
contains(BillingClient.BillingResponseCode.USER_CANCELED) shouldBe true
contains(BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED) shouldBe true
contains(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE) shouldBe true
contains(BillingClient.BillingResponseCode.ERROR) shouldBe true
contains(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE) shouldBe true
contains(BillingClient.BillingResponseCode.SERVICE_DISCONNECTED) shouldBe true
contains(BillingClient.BillingResponseCode.SERVICE_TIMEOUT) shouldBe true
contains(BillingClient.BillingResponseCode.NETWORK_ERROR) shouldBe true
contains(BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED) shouldBe true
// Actionable defects must keep reporting.
contains(BillingClient.BillingResponseCode.DEVELOPER_ERROR) shouldBe false
contains(BillingClient.BillingResponseCode.ITEM_UNAVAILABLE) shouldBe false
}
}
@Test
fun `connection retry uses capped backoff and retries early on foreground entry`() = runTest2 {
val testScope = TestScope(UnconfinedTestDispatcher(testScheduler))
var attempts = 0
val provider = mockk<BillingClientConnectionProvider> {
every { connection } returns flow {
attempts++
throw BillingException("still broken")
}
}
val foreground = MutableStateFlow(false)
val foregroundState = mockk<AppForegroundState> {
every { isForeground } returns foreground
}
BillingDataRepo(provider, testScope, foregroundState, TestTimeSource())
testScope.testScheduler.runCurrent()
attempts shouldBe 1
// First backoff is 60s — not a second sooner.
testScope.testScheduler.advanceTimeBy(59_000)
testScope.testScheduler.runCurrent()
attempts shouldBe 1
testScope.testScheduler.advanceTimeBy(2_000)
testScope.testScheduler.runCurrent()
attempts shouldBe 2
// Second backoff would be 120s — a foreground entry short-circuits it, so a user
// returning from e.g. Google sign-in doesn't wait out the full backoff.
testScope.testScheduler.advanceTimeBy(5_000)
foreground.value = true
testScope.testScheduler.runCurrent()
attempts shouldBe 3
testScope.cancel()
}
@Test
fun `user canceled maps to UserCanceledBillingException`() {
val result = mockBillingResult(BillingClient.BillingResponseCode.USER_CANCELED)