diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientExtensions.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientExtensions.kt index fb025feb..b1bd6a4d 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientExtensions.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/client/BillingClientExtensions.kt @@ -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 diff --git a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt index 27e5d663..c7e15fe6 100644 --- a/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt +++ b/app/src/gplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepo.kt @@ -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 { diff --git a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt index c1e5d5c0..1fe2de25 100644 --- a/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt +++ b/app/src/gplay/java/eu/darken/capod/upgrade/ui/UpgradeScreen.kt @@ -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 { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1906a8c8..90c100b0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,6 +32,7 @@ Feature availability depends on your headphones and device. Same features, different pricing. The subscription includes a free trial and can be cancelled at any time. + Same features, different pricing. The subscription can be cancelled at any time. Start free trial Subscribe yearly diff --git a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepoTest.kt b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepoTest.kt index 6718f960..55906203 100644 --- a/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepoTest.kt +++ b/app/src/testGplay/java/eu/darken/capod/common/upgrade/core/data/BillingDataRepoTest.kt @@ -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() } + @Test + fun `network error maps to GplayServiceUnavailableException`() { + val result = mockBillingResult(BillingClient.BillingResponseCode.NETWORK_ERROR) + val mapped = BillingResultException(result).tryMapUserFriendly() + mapped.shouldBeInstanceOf() + } + + @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 { + every { connection } returns flow { + attempts++ + throw BillingException("still broken") + } + } + val foreground = MutableStateFlow(false) + val foregroundState = mockk { + 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)