mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
fix(upgrade): Cut report noise, pace retries and fix trial wording
This commit is contained in:
+2
-1
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+65
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user