8000 BillingClient connection retries (#4269) · CodersSampling/DuckDuckGoAndroid@4b8a77d · GitHub
[go: up one dir, main page]

Skip to content

Commit 4b8a77d

Browse files
authored
BillingClient connection retries (duckduckgo#4269)
<!-- Note: This checklist is a reminder of our shared engineering expectations. The items in Bold are required If your PR involves UI changes: 1. Upload screenshots or screencasts that illustrate the changes before / after 2. Add them under the UI changes section (feel free to add more columns if needed) If your PR does not involve UI changes, you can remove the **UI changes** section At a minimum, make sure your changes are tested in API 23 and one of the more recent API levels available. --> Task/Issue URL: https://app.asana.com/0/1202552961248957/1206760049330314/f ### Description See task. ### No UI changes
1 parent 87dd4a8 commit 4b8a77d

File tree

7 files changed

+431
-61
lines changed

7 files changed

+431
-61
lines changed

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt

Lines changed: 19 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import com.duckduckgo.subscriptions.impl.SubscriptionStatus.Unknown
3232
import com.duckduckgo.subscriptions.impl.SubscriptionsData.*
3333
import com.duckduckgo.subscriptions.impl.billing.PlayBillingManager
3434
import com.duckduckgo.subscriptions.impl.billing.PurchaseState
35+
import com.duckduckgo.subscriptions.impl.billing.RetryPolicy
36+
import com.duckduckgo.subscriptions.impl.billing.retry
3537
import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender
3638
import com.duckduckgo.subscriptions.impl.repository.AuthRepository
3739
import com.duckduckgo.subscriptions.impl.services.AuthService
@@ -47,9 +49,9 @@ import com.squareup.moshi.JsonEncodingException
4749
import com.squareup.moshi.Moshi
4850
import dagger.SingleInstanceIn
4951
import javax.inject.Inject
52+
import kotlin.time.Duration.Companion.milliseconds
5053
import kotlinx.coroutines.CoroutineScope
5154
import kotlinx.coroutines.Job
52-
import kotlinx.coroutines.delay
5355
import kotlinx.coroutines.flow.Flow
5456
import kotlinx.coroutines.flow.MutableSharedFlow
5557
import kotlinx.coroutines.flow.MutableStateFlow
@@ -249,38 +251,28 @@ class RealSubscriptionsManager @Inject constructor(
249251
packageName: String,
250252
purchaseToken: String,
251253
) {
254+
_currentPurchaseState.emit(CurrentPurchase.InProgress)
255+
252256
var retryCompleted = false
253257

254-
suspend fun retry(
255-
times: Int = 3,
256-
initialDelay: Long = 500, // .5 seconds
257-
maxDelay: Long = 1_500, // 1.5 seconds
258-
factor: Double = 2.0,
259-
block: suspend () -> Unit,
258+
retry(
259+
retryPolicy = RetryPolicy(
260+
retryCount = 2,
261+
initialDelay = 500.milliseconds,
262+
maxDelay = 1500.milliseconds,
263+
delayIncrementFactor = 2.0,
264+
),
260265
) {
261-
var currentDelay = initialDelay
262-
repeat(times) {
263-
try {
264-
if (!retryCompleted) {
265-
block()
266-
} else {
267-
return@retry
268-
}
269-
} catch (t: Throwable) {
270-
logcat { "Subs: error in confirmation retry" }
271-
}
272-
delay(currentDelay)
273-
currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay)
266+
try {
267+
retryCompleted = attemptConfirmPurchase(packageName, purchaseToken)
268+
logcat { "Subs: retry success: $retryCompleted" }
269+
retryCompleted
270+
} catch (e: Throwable) {
271+
logcat { "Subs: error in confirmation retry" }
272+
false
274273
}
275274
}
276275

277-
_currentPurchaseState.emit(CurrentPurchase.InProgress)
278-
279-
retry {
280-
retryCompleted = attemptConfirmPurchase(packageName, purchaseToken)
281-
logcat { "Subs: retry success: $retryCompleted" }
282-
}
283-
284276
if (!retryCompleted) {
285277
handlePurchaseFailed()
286278
}

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/BillingClientAdapter.kt

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,14 @@ interface BillingClientAdapter {
4242

4343
sealed class BillingInitResult {
4444
data object Success : BillingInitResult()
45-
data object Failure : BillingInitResult()
45+
data class Failure(val billingError: BillingError) : BillingInitResult()
4646
}
4747

4848
sealed class SubscriptionsResult {
4949
data class Success(val products: List<ProductDetails>) : SubscriptionsResult()
5050

5151
data class Failure(
52-
val billingResponseCode: Int? = null,
52+
val billingError: BillingError? = null,
5353
val debugMessage: String? = null,
5454
) : SubscriptionsResult()
5555
}
@@ -74,3 +74,20 @@ sealed class PurchasesUpdateResult {
7474
data object UserCancelled : PurchasesUpdateResult()
7575
data object Failure : PurchasesUpdateResult()
7676
}
77+
78+
enum class BillingError {
79+
SERVICE_TIMEOUT,
80+
FEATURE_NOT_SUPPORTED,
81+
SERVICE_DISCONNECTED,
82+
USER_CANCELED,
83+
SERVICE_UNAVAILABLE,
84+
BILLING_UNAVAILABLE,
85+
ITEM_UNAVAILABLE,
86+
DEVELOPER_ERROR,
87+
ERROR,
88+
ITEM_ALREADY_OWNED,
89+
ITEM_NOT_OWNED,
90+
NETWORK_ERROR,
91+
UNKNOWN_ERROR,
92+
;
93+
}

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/PlayBillingManager.kt

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ import com.duckduckgo.app.di.AppCoroutineScope
2525
import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver
2626
import com.duckduckgo.di.scopes.AppScope
2727
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.LIST_OF_PRODUCTS
28+
import com.duckduckgo.subscriptions.impl.billing.BillingError.ERROR
29+
import com.duckduckgo.subscriptions.impl.billing.BillingError.NETWORK_ERROR
30+
import com.duckduckgo.subscriptions.impl.billing.BillingError.SERVICE_DISCONNECTED
31+
import com.duckduckgo.subscriptions.impl.billing.BillingError.SERVICE_UNAVAILABLE
2832
import com.duckduckgo.subscriptions.impl.billing.BillingInitResult.Failure
2933
import com.duckduckgo.subscriptions.impl.billing.BillingInitResult.Success
3034
import com.duckduckgo.subscriptions.impl.billing.PurchaseState.Canceled
@@ -37,12 +41,18 @@ import com.duckd 10000 uckgo.subscriptions.impl.pixels.SubscriptionPixelSender
3741
import com.squareup.anvil.annotations.ContributesBinding
3842
import com.squareup.anvil.annotations.ContributesMultibinding
3943
import dagger.SingleInstanceIn
44+
import java.util.EnumSet
4045
import javax.inject.Inject
46+
import kotlin.time.Duration.Companion.minutes
47+
import kotlin.time.Duration.Companion.seconds
4148
import kotlinx.coroutines.CoroutineScope
49+
import kotlinx.coroutines.Job
4250
import kotlinx.coroutines.flow.Flow
4351
import kotlinx.coroutines.flow.MutableSharedFlow
4452
import kotlinx.coroutines.flow.asSharedFlow
4553
import kotlinx.coroutines.launch
54+
import kotlinx.coroutines.sync.Mutex
55+
import kotlinx.coroutines.sync.withLock
4656
import logcat.logcat
4757

4858
interface PlayBillingManager {
@@ -67,6 +77,8 @@ class RealPlayBillingManager @Inject constructor(
6777
private val billingClient: BillingClientAdapter,
6878
) : PlayBillingManager, MainProcessLifecycleObserver {
6979

80+
private val connectionMutex = Mutex()
81+
private var connectionJob: Job? = null
7082
private var billingFlowInProgress = false
7183

7284
// PurchaseState
@@ -80,7 +92,7 @@ class RealPlayBillingManager @Inject constructor(
8092
override var purchaseHistory = emptyList<PurchaseHistoryRecord>()
8193

8294
override fun onCreate(owner: LifecycleOwner) {
83-
coroutineScope.launch { connect() }
95+
connectAsyncWithRetry()
8496
}
8597

8698
override fun onResume(owner: LifecycleOwner) {
@@ -95,20 +107,47 @@ class RealPlayBillingManager @Inject constructor(
95107
}
96108
}
97109

98-
private suspend fun connect() {
99-
val result = billingClient.connect(
100-
purchasesListener = { result -> onPurchasesUpdated(result) },
101-
disconnectionListener = { onBillingClientDisconnected() },
102-
)
110+
private fun connectAsyncWithRetry() {
111+
if (connectionJob?.isActive == true) return
112+
113+
connectionJob = coroutineScope.launch {
114+
connect(
115+
retryPolicy = RetryPolicy(
116+
retryCount = 5,
117+
initialDelay = 1.seconds,
118+
maxDelay = 5.minutes,
119+
delayIncrementFactor = 4.0,
120+
),
121+
)
122+
}
123+
}
103124

104-
when (result) {
105-
Success -> {
106-
loadProducts()
107-
loadPurchaseHistory()
108-
}
125+
private suspend fun connect(retryPolicy: RetryPolicy? = null) = retry(retryPolicy) {
126+
connectionMutex.withLock {
127+
if (billingClient.ready) return@withLock true
109128

110-
Failure -> {
111-
logcat { "Service error" }
129+
val result = billingClient.connect(
130+
purchasesListener = { result -> onPurchasesUpdated(result) },
131+
disconnectionListener = { onBillingClientDisconnected() },
132+
)
133+
134+
when (result) {
135+
Success -> {
136+
loadProducts()
137+
loadPurchaseHistory()
138+
true // success, don't retry
139+
}
140+
141+
is Failure -> {
142+
logcat { "Service error" }
143+
val recoverable = result.billingError in EnumSet.of(
144+
ERROR,
145+
SERVICE_DISCONNECTED,
146+
SERVICE_UNAVAILABLE,
147+
NETWORK_ERROR,
148+
)
149+
!recoverable // complete without retry if error is not recoverable
150+
}
112151
}
113152
}
114153
}
@@ -121,6 +160,7 @@ class RealPlayBillingManager @Inject constructor(
121160
) {
122161
if (!billingClient.ready) {
123162
logcat { "Service not ready" }
163+
connect()
124164
}
125165

126166
val launchBillingFlowResult = billingClient.launchBillingFlow(
@@ -172,6 +212,7 @@ class RealPlayBillingManager @Inject constructor(
172212

173213
private fun onBillingClientDisconnected() {
174214
logcat { "Service disconnected" }
215+
connectAsyncWithRetry()
175216
}
176217

177218
private suspend fun loadProducts() {
@@ -184,17 +225,12 @@ class RealPlayBillingManager @Inject constructor(
184225
}
185226

186227
is SubscriptionsResult.Failure -> {
187-
logcat { "onProductDetailsResponse: ${result.billingResponseCode} ${result.debugMessage}" }
228+
logcat { "onProductDetailsResponse: ${result.billingError} ${result.debugMessage}" }
188229
}
189230
}
190231
}
191232

192233
private suspend fun loadPurchaseHistory() {
193-
if (!billingClient.ready) {
194-
// Handle client not ready
195-
return
196-
}
197-
198234
when (val result = billingClient.getSubscriptionsPurchaseHistory()) {
199235
is SubscriptionsPurchaseHistoryResult.Success -> {
200236
purchaseHistory = result.history

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/RealBillingClientAdapter.kt

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import com.duckduckgo.subscriptions.impl.billing.BillingInitResult.Failure
3838
import com.duckduckgo.subscriptions.impl.billing.BillingInitResult.Success
3939
import com.squareup.anvil.annotations.ContributesBinding
4040
import dagger.SingleInstanceIn
41+
import java.lang.IllegalArgumentException
4142
import javax.inject.Inject
4243
import kotlinx.coroutines.ExperimentalCoroutinesApi
4344
import kotlinx.coroutines.suspendCancellableCoroutine
@@ -80,7 +81,7 @@ class RealBillingClientAdapter @Inject constructor(
8081
override fun onBillingSetupFinished(p0: BillingResult) {
8182
val result = when (p0.responseCode) {
8283
BillingResponseCode.OK -> Success
83-
else -> Failure
84+
else -> Failure(billingError = p0.responseCode.toBillingError())
8485
}
8586

8687
continuation.resume(result, onCancellation = null)
@@ -109,7 +110,7 @@ class RealBillingClientAdapter @Inject constructor(
109110

110111
return when (billingResult.responseCode) {
111112
BillingResponseCode.OK -> SubscriptionsResult.Success(productDetails.orEmpty())
112-
else -> SubscriptionsResult.Failure(billingResult.responseCode, billingResult.debugMessage)
113+
else -> SubscriptionsResult.Failure(billingResult.responseCode.toBillingError(), billingResult.debugMessage)
113114
}
114115
}
115116

@@ -187,3 +188,20 @@ class RealBillingClientAdapter @Inject constructor(
187188
else -> PurchasesUpdateResult.Failure
188189
}
189190
}
191+
192+
private fun Int.toBillingError(): BillingError = when (this) {
193+
BillingResponseCode.OK -> throw IllegalArgumentException()
194+
BillingResponseCode.SERVICE_TIMEOUT -> BillingError.SERVICE_TIMEOUT
195+
BillingResponseCode.FEATURE_NOT_SUPPORTED -> BillingError.FEATURE_NOT_SUPPORTED
196+
BillingResponseCode.SERVICE_DISCONNECTED -> BillingError.SERVICE_DISCONNECTED
197+
BillingResponseCode.USER_CANCELED -> BillingError.USER_CANCELED
198+
BillingResponseCode.SERVICE_UNAVAILABLE -> BillingError.SERVICE_UNAVAILABLE
199+
BillingResponseCode.BILLING_UNAVAILABLE -> BillingError.BILLING_UNAVAILABLE
200+
BillingResponseCode.ITEM_UNAVAILABLE -> BillingError.ITEM_UNAVAILABLE
201+
BillingResponseCode.DEVELOPER_ERROR -> BillingError.DEVELOPER_ERROR
202+
BillingResponseCode.ERROR -> BillingError.ERROR
203+
BillingResponseCode.ITEM_ALREADY_OWNED -> BillingError.ITEM_ALREADY_OWNED
204+
BillingResponseCode.ITEM_NOT_OWNED -> BillingError.ITEM_NOT_OWNED
205+
BillingResponseCode.NETWORK_ERROR -> BillingError.NETWORK_ERROR
206+
else -> BillingError.UNKNOWN_ERROR
207+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* Copyright (c) 2024 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.subscriptions.impl.billing
18+
19+
import kotlin.time.Duration
20+
import kotlinx.coroutines.delay
21+
22+
/**
23+
* Retries a given block of code according to the retry policy defined in [retryPolicy].
24+
* The block is executed immediately and then, based on its result and the provided [retryPolicy],
25+
* may be retried several times with delays between attempts. The method will stop retrying and
26+
* return as soon as the block returns `true` or the retry limits are exhausted.
27+
*
28+
* @param retryPolicy An optional [RetryPolicy] object specifying the retry policy, including
29+
* the number of retries, the delay before the first retry, the maximum delay, and the delay increment factor.
30+
* If null, the block will be executed once without retries.
31+
* @param block The suspending block of code to be executed and possibly retried. This block should
32+
* return `true` to indicate success and avoid further retries, or `false` to indicate failure and
33+
* potentially trigger another retry attempt according to the retry policy.
34+
*
35+
* @throws Throwable whatever exceptions [block] may throw if it fails. Note that exceptions do not trigger retries,
36+
* and are instead propagated immediately to the caller of `retry()`.
37+
*
38+
* Usage example:
39+
* ```
40+
* suspend fun mightFailOperation() {
41+
* // Implementation here
42+
* }
43+
*
44+
* val retryPolicy = RetryPolicy(
45+
* retryCount = 3,
46+
* initialDelay = 500.milliseconds,
47+
* maxDelay = 10.seconds,
48+
* delayIncrementFactor = 2.0
49+
* )
50+
*
51+
* retry(retryPolicy) {
52+
* return try {
53+
* mightFailOperation()
54+
* true
55+
* } catch (e: IOException) {
56+
* false
57+
* } catch (e: YouCantRecoverFromThisException) {
58+
* true
59+
* }
60+
* }
61+
* ```
62+
*/
63+
suspend fun retry(
64+
retryPolicy: RetryPolicy?,
65+
block: suspend () -> Boolean,
66+
) {
67+
if (block() || retryPolicy == null) return
68+
69+
val delayDurations = with(retryPolicy) {
70+
generateSequence(initialDelay) { (it * delayIncrementFactor).coerceAtMost(maxDelay) }
71+
.iterator()
72+
}
73+
74+
repeat(times = retryPolicy.retryCount) {
75+
delay(duration = delayDurations.next())
76+
if (block()) return
77+
}
78+
}
79+
80+
data class RetryPolicy(
81+
val retryCount: Int,
82+
val initialDelay: Duration,
83+
val maxDelay: Duration,
84+
val delayIncrementFactor: Double,
85+
)

0 commit comments

Comments
 (0)
0