Skip to content

Commit 538a40e

Browse files
authored
Merge pull request #376 from superwall/ir/fix/billing-retry
Ir/fix/billing retry
2 parents 78fea3a + 5bfdc7a commit 538a40e

11 files changed

Lines changed: 1738 additions & 81 deletions

File tree

superwall/src/androidTest/java/com/superwall/sdk/billing/GoogleBillingWrapperTest.kt

Lines changed: 840 additions & 0 deletions
Large diffs are not rendered by default.

superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,26 @@ class GoogleBillingWrapper(
4848
val ioScope: IOScope,
4949
val appLifecycleObserver: AppLifecycleObserver,
5050
val factory: Factory,
51+
val createBillingClient: (PurchasesUpdatedListener) -> BillingClient = {
52+
BillingClient
53+
.newBuilder(context)
54+
.setListener(it)
55+
.enablePendingPurchases(
56+
PendingPurchasesParams.newBuilder().enableOneTimeProducts().build(),
57+
).build()
58+
},
5159
) : PurchasesUpdatedListener,
5260
BillingClientStateListener,
5361
Billing {
5462
companion object {
5563
private val productsCache = ConcurrentHashMap<String, Either<StoreProduct, Throwable>>()
5664
private const val QUERY_PURCHASES_TIMEOUT_MS = 10_000L
5765
private const val QUERY_PURCHASES_MAX_RETRIES = 3
66+
67+
@androidx.annotation.VisibleForTesting
68+
internal fun clearProductsCache() {
69+
productsCache.clear()
70+
}
5871
}
5972

6073
interface Factory :
@@ -164,13 +177,7 @@ class GoogleBillingWrapper(
164177
fun startConnection() {
165178
synchronized(this@GoogleBillingWrapper) {
166179
if (billingClient == null) {
167-
billingClient =
168-
BillingClient
169-
.newBuilder(context)
170-
.setListener(this@GoogleBillingWrapper)
171-
.enablePendingPurchases(
172-
PendingPurchasesParams.newBuilder().enableOneTimeProducts().build(),
173-
).build()
180+
billingClient = createBillingClient(this)
174181
}
175182

176183
reconnectionAlreadyScheduled = false
@@ -258,9 +265,14 @@ class GoogleBillingWrapper(
258265
}
259266

260267
override fun onError(error: BillingError) {
261-
// Identify and handle missing products
262-
missingFullProductIds.forEach { fullProductId ->
263-
productsCache[fullProductId] = Either.Failure(error)
268+
// Cache BillingNotAvailable — it's a permanent device state
269+
// that won't resolve, so retrying is wasteful.
270+
// Other billing errors (service unavailable, disconnected, network)
271+
// are transient and should NOT be cached to allow retry.
272+
if (error is BillingError.BillingNotAvailable) {
273+
missingFullProductIds.forEach { fullProductId ->
274+
productsCache[fullProductId] = Either.Failure(error)
275+
}
264276
}
265277
continuation.resumeWithException(error)
266278
}

superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,17 @@ class PaywallRequestManager(
8181
!request.isDebuggerLaunched
8282
) {
8383
if (!(isPreloading && paywall.identifier == factory.activePaywallId())) {
84+
// If products failed to load previously (e.g. billing was unavailable
85+
// during preload), retry loading them now.
86+
if (paywall.productsLoadingInfo.failAt != null && paywall.productIds.isNotEmpty()) {
87+
// Clear failAt before retry. StoreManager.getProducts will re-set it
88+
// if a transient error occurs, so we can check afterward.
89+
paywall.productsLoadingInfo.failAt = null
90+
paywall = addProducts(paywall, request)
91+
if (paywall.productsLoadingInfo.failAt == null) {
92+
paywallsByHash[requestHash] = paywall
93+
}
94+
}
8495
return@withContext updatePaywall(paywall, request)
8596
} else {
8697
return@withContext paywall

superwall/src/main/java/com/superwall/sdk/paywall/view/PaywallView.kt

Lines changed: 38 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -299,30 +299,34 @@ class PaywallView(
299299
"Timeout triggered - paywall wasn't loaded in ${timeout.inWholeSeconds} seconds"
300300
controller.currentState
301301
.filter { it.loadingState == PaywallLoadingState.Ready }
302+
.map { Result.success(it.loadingState) }
302303
.timeout(timeout)
303-
.catch {
304-
if (it is TimeoutCancellationException) {
304+
.catch { err ->
305+
Result.failure<PaywallLoadingState>(err)
306+
}.first()
307+
.onFailure { e ->
308+
if (e is TimeoutCancellationException) {
305309
state.paywallStatePublisher?.emit(
306310
PaywallState.PresentationError(
307311
PaywallErrors.Timeout(msg),
308312
),
309313
)
310314
mainScope.launch {
311315
updateState(WebLoadingFailed)
312-
313-
val trackedEvent =
314-
InternalSuperwallEvent.PaywallWebviewLoad(
315-
state =
316-
InternalSuperwallEvent.PaywallWebviewLoad.State.Fail(
317-
WebviewError.Timeout(msg),
318-
listOf(info.url.value),
319-
),
320-
paywallInfo = info,
321-
)
322-
factory.track(trackedEvent)
323316
}
317+
318+
val trackedEvent =
319+
InternalSuperwallEvent.PaywallWebviewLoad(
320+
state =
321+
InternalSuperwallEvent.PaywallWebviewLoad.State.Fail(
322+
WebviewError.Timeout(msg),
323+
listOf(info.url.value),
324+
),
325+
paywallInfo = info,
326+
)
327+
factory.track(trackedEvent)
324328
}
325-
}.first()
329+
}
326330
}
327331
}
328332

@@ -372,19 +376,19 @@ class PaywallView(
372376
factory
373377
.delegate()
374378
.willPresentPaywall(info)
375-
/*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
376-
try {
377-
// Temporary disabled
378-
// webView.setRendererPriorityPolicy(RENDERER_PRIORITY_IMPORTANT, true)
379-
} catch (e: Throwable) {
380-
Logger.debug(
381-
LogLevel.info,
382-
LogScope.paywallView,
383-
"Cannot set webview priority when beginning presentation",
384-
error = e,
385-
)
386-
}
387-
}*/
379+
/*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
380+
try {
381+
// Temporary disabled
382+
// webView.setRendererPriorityPolicy(RENDERER_PRIORITY_IMPORTANT, true)
383+
} catch (e: Throwable) {
384+
Logger.debug(
385+
LogLevel.info,
386+
LogScope.paywallView,
387+
"Cannot set webview priority when beginning presentation",
388+
error = e,
389+
)
390+
}
391+
}*/
388392
webView.scrollTo(0, 0)
389393
if (loadingState is PaywallLoadingState.Ready) {
390394
webView.messageHandler.handle(PaywallMessage.TemplateParamsAndUserAttributes)
@@ -558,9 +562,9 @@ class PaywallView(
558562
}
559563
}
560564

561-
//endregion
565+
//endregion
562566

563-
//region Lifecycle
567+
//region Lifecycle
564568

565569
override fun onAttachedToWindow() {
566570
super.onAttachedToWindow()
@@ -584,7 +588,7 @@ class PaywallView(
584588
}
585589

586590
// Lets the view know that presentation has finished.
587-
// Only called once per presentation.
591+
// Only called once per presentation.
588592
fun onViewCreated() {
589593
state.viewCreatedCompletion?.invoke(true)
590594
controller.updateState(ClearViewCreatedCompletion)
@@ -648,9 +652,9 @@ class PaywallView(
648652
}
649653
}
650654

651-
//endregion
655+
//endregion
652656

653-
//region Presentation
657+
//region Presentation
654658

655659
private fun dismiss(presentationIsAnimated: Boolean) {
656660
// TODO: SW-2162 Implement animation support
@@ -782,9 +786,9 @@ class PaywallView(
782786
}
783787
}
784788

785-
//endregion
789+
//endregion
786790

787-
//region State
791+
//region State
788792

789793
internal fun loadingStateDidChange() {
790794
if (state.isPresented) {

superwall/src/main/java/com/superwall/sdk/paywall/view/SuperwallPaywallActivity.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -615,7 +615,10 @@ class SuperwallPaywallActivity : AppCompatActivity() {
615615
if (isModal && newState == BottomSheetBehavior.STATE_HALF_EXPANDED) {
616616
bottomSheetBehavior.state = BottomSheetBehavior.STATE_EXPANDED
617617
} else if (newState == BottomSheetBehavior.STATE_HIDDEN) {
618-
finish()
618+
paywallView()?.dismiss(
619+
result = PaywallResult.Declined(),
620+
closeReason = PaywallCloseReason.ManualClose,
621+
) ?: finish()
619622
}
620623
}
621624
}

superwall/src/main/java/com/superwall/sdk/paywall/view/webview/messaging/PaywallMessageHandler.kt

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -420,16 +420,12 @@ class PaywallMessageHandler(
420420
// block selection
421421
messageHandler?.evaluate(selectionString, null)
422422
messageHandler?.evaluate(preventZoom, null)
423-
ioScope.launch {
424-
mainScope.launch {
425-
flushPendingMessagesInternal()
426-
messageHandler?.updateState(
427-
PaywallViewState.Updates.SetLoadingState(
428-
PaywallLoadingState.Ready,
429-
),
430-
)
431-
}
432-
}
423+
flushPendingMessagesInternal()
424+
messageHandler?.updateState(
425+
PaywallViewState.Updates.SetLoadingState(
426+
PaywallLoadingState.Ready,
427+
),
428+
)
433429
}
434430
}
435431

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.superwall.sdk.store
2+
3+
import com.superwall.sdk.store.abstractions.product.StoreProduct
4+
import kotlinx.coroutines.CompletableDeferred
5+
6+
sealed class ProductState {
7+
class Loading(
8+
val deferred: CompletableDeferred<StoreProduct> = CompletableDeferred(),
9+
) : ProductState()
10+
11+
data class Loaded(
12+
val product: StoreProduct,
13+
) : ProductState()
14+
15+
data class Error(
16+
val error: Throwable,
17+
) : ProductState()
18+
}

0 commit comments

Comments
 (0)