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)