diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt index 9cab55990a8f..587c375189da 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt @@ -384,6 +384,8 @@ class RealSubscriptionsManager @Inject constructor( checkPurchase(it.packageName, it.purchaseToken) } is PurchaseState.Canceled -> { + subscriptionPurchaseWideEvent.onPurchaseCancelledByUser() + subscriptionSwitchWideEvent.onUserCancelled() _currentPurchaseState.emit(CurrentPurchase.Canceled) if (removeExpiredSubscriptionOnCancelledPurchase) { if (subscriptionStatus().isExpired()) { @@ -393,6 +395,12 @@ class RealSubscriptionsManager @Inject constructor( } } + is PurchaseState.Failure -> { + subscriptionPurchaseWideEvent.onBillingFlowPurchaseFailure(it.errorType) + subscriptionSwitchWideEvent.onSwitchFailed(it.errorType) + _currentPurchaseState.emit(CurrentPurchase.Failure(it.errorType)) + } + else -> { // NOOP } @@ -534,20 +542,14 @@ class RealSubscriptionsManager @Inject constructor( val currentPurchaseToken = playBillingManager.getLatestPurchaseToken() if (currentPurchaseToken == null) { - val errorMessage = "No current purchase token found for switch" - logcat { "Subs: Cannot switch plan - $errorMessage" } - subscriptionSwitchWideEvent.onSwitchFailed(errorMessage) - _currentPurchaseState.emit(CurrentPurchase.Failure(errorMessage)) + _currentPurchaseState.emit(CurrentPurchase.Failure("No current purchase token found for switch")) return@withContext } // Get account details for external ID val account = authRepository.getAccount() if (account == null) { - val errorMessage = "No account found for switch" - logcat { "Subs: Cannot switch plan - $errorMessage" } - subscriptionSwitchWideEvent.onSwitchFailed(errorMessage) - _currentPurchaseState.emit(CurrentPurchase.Failure(errorMessage)) + _currentPurchaseState.emit(CurrentPurchase.Failure("No account found for switch")) return@withContext } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/PlayBillingManager.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/PlayBillingManager.kt index f895368b3908..9cff767e8576 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/PlayBillingManager.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/PlayBillingManager.kt @@ -224,8 +224,9 @@ class RealPlayBillingManager @Inject constructor( ?.offerToken if (productDetails == null || offerToken == null) { - subscriptionPurchaseWideEvent.onBillingFlowInitFailure(error = "Missing product details") - _purchaseState.emit(Canceled) + val error = "Missing product details" + subscriptionPurchaseWideEvent.onBillingFlowInitFailure(error = error) + _purchaseState.emit(PurchaseState.Failure(error)) return@withContext } @@ -246,7 +247,7 @@ class RealPlayBillingManager @Inject constructor( is LaunchBillingFlowResult.Failure -> { val error = "Billing error: ${launchBillingFlowResult.error.name}" subscriptionPurchaseWideEvent.onBillingFlowInitFailure(error) - _purchaseState.emit(Canceled) + _purchaseState.emit(PurchaseState.Failure(error)) } } } @@ -270,7 +271,7 @@ class RealPlayBillingManager @Inject constructor( val errorMessage = "empty old purchase token" logcat { "Billing: $errorMessage" } subscriptionSwitchWideEvent.onBillingFlowInitFailure(errorMessage) - _purchaseState.emit(Canceled) + _purchaseState.emit(PurchaseState.Failure(errorMessage)) return@withContext } @@ -285,7 +286,7 @@ class RealPlayBillingManager @Inject constructor( val errorMessage = "Missing product details" logcat { "Billing: $errorMessage" } subscriptionSwitchWideEvent.onBillingFlowInitFailure(errorMessage) - _purchaseState.emit(Canceled) + _purchaseState.emit(PurchaseState.Failure(errorMessage)) return@withContext } @@ -307,7 +308,7 @@ class RealPlayBillingManager @Inject constructor( is LaunchBillingFlowResult.Failure -> { subscriptionSwitchWideEvent.onBillingFlowInitFailure(launchBillingFlowResult.error.name) - _purchaseState.emit(Canceled) + _purchaseState.emit(PurchaseState.Failure(launchBillingFlowResult.error.name)) } } } @@ -326,17 +327,13 @@ class RealPlayBillingManager @Inject constructor( PurchaseAbsent -> {} UserCancelled -> { - subscriptionPurchaseWideEvent.onPurchaseCancelledByUser() - subscriptionSwitchWideEvent.onUserCancelled() _purchaseState.emit(Canceled) // Handle an error caused by a user cancelling the purchase flow. } is PurchasesUpdateResult.Failure -> { - subscriptionPurchaseWideEvent.onBillingFlowPurchaseFailure(result.errorType) - subscriptionSwitchWideEvent.onSwitchFailed(result.errorType) pixelSender.reportPurchaseFailureStore(result.errorType) - _purchaseState.emit(Canceled) + _purchaseState.emit(PurchaseState.Failure(result.errorType)) } } } @@ -413,4 +410,5 @@ sealed class PurchaseState { ) : PurchaseState() data object Canceled : PurchaseState() + data class Failure(val errorType: String) : PurchaseState() } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt index fdceac21609f..7164a4584514 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt @@ -40,6 +40,7 @@ import com.duckduckgo.subscriptions.impl.auth2.TokenPair import com.duckduckgo.subscriptions.impl.billing.PlayBillingManager import com.duckduckgo.subscriptions.impl.billing.PurchaseState import com.duckduckgo.subscriptions.impl.billing.PurchaseState.Canceled +import com.duckduckgo.subscriptions.impl.billing.PurchaseState.Failure import com.duckduckgo.subscriptions.impl.billing.PurchaseState.Purchased import com.duckduckgo.subscriptions.impl.billing.SubscriptionReplacementMode import com.duckduckgo.subscriptions.impl.model.Entitlement @@ -788,6 +789,43 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { } } + @Test + fun whenPurchaseFailedThenEmitFailure() = runTest { + val flowTest: MutableSharedFlow = MutableSharedFlow() + whenever(playBillingManager.purchaseState).thenReturn(flowTest) + + val manager = RealSubscriptionsManager( + authService, + subscriptionsService, + authRepository, + playBillingManager, + emailManager, + context, + TestScope(), + coroutineRule.testDispatcherProvider, + pixelSender, + { privacyProFeature }, + authClient, + authJwtValidator, + pkceGenerator, + timeProvider, + backgroundTokenRefresh, + subscriptionPurchaseWideEvent, + tokenRefreshWideEvent, + subscriptionSwitchWideEvent, + freeTrialConversionWideEvent, + subscriptionRestoreWideEvent, + ) + + manager.currentPurchaseState.test { + flowTest.emit(Failure("BILLING_UNAVAILABLE")) + val result = awaitItem() + assertTrue(result is CurrentPurchase.Failure) + assertEquals("BILLING_UNAVAILABLE", (result as CurrentPurchase.Failure).message) + cancelAndConsumeRemainingEvents() + } + } + @Test fun whenGetAccessTokenIfUserIsSignedInThenReturnSuccess() = runTest { givenUserIsSignedIn() diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/billing/RealPlayBillingManagerTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/billing/RealPlayBillingManagerTest.kt index ad5fa8d00a60..78d7289aad0f 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/billing/RealPlayBillingManagerTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/billing/RealPlayBillingManagerTest.kt @@ -23,7 +23,6 @@ import com.duckduckgo.subscriptions.impl.billing.FakeBillingClientAdapter.FakeMe import com.duckduckgo.subscriptions.impl.billing.FakeBillingClientAdapter.FakeMethodInvocation.LaunchBillingFlow import com.duckduckgo.subscriptions.impl.billing.FakeBillingClientAdapter.FakeMethodInvocation.LaunchSubscriptionUpdate import com.duckduckgo.subscriptions.impl.billing.FakeBillingClientAdapter.FakeMethodInvocation.QueryPurchases -import com.duckduckgo.subscriptions.impl.billing.PurchaseState.Canceled import com.duckduckgo.subscriptions.impl.billing.PurchaseState.InProgress import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceTimeBy @@ -121,7 +120,7 @@ class RealPlayBillingManagerTest { } @Test - fun `when can't connect to service then launching billing flow is cancelled`() = runTest { + fun `when can't connect to service then launching billing flow fails`() = runTest { billingClientAdapter.billingInitResult = BillingInitResult.Failure(BILLING_UNAVAILABLE) processLifecycleOwner.currentState = RESUMED billingClientAdapter.launchBillingFlowResult = LaunchBillingFlowResult.Failure(error = SERVICE_UNAVAILABLE) @@ -134,7 +133,7 @@ class RealPlayBillingManagerTest { subject.launchBillingFlow(activity = mock(), planId = MONTHLY_PLAN_US, externalId, null) - assertEquals(Canceled, awaitItem()) + assertEquals(PurchaseState.Failure("Missing product details"), awaitItem()) } billingClientAdapter.verifyConnectInvoked() @@ -255,7 +254,7 @@ class RealPlayBillingManagerTest { } @Test - fun `when launchSubscriptionUpdate called with invalid plan then emits canceled state`() = runTest { + fun `when launchSubscriptionUpdate called with invalid plan then emits failure state`() = runTest { // Set up purchase history so getCurrentPurchaseToken() returns a valid token val mockPurchase: PurchaseHistoryRecord = mock { whenever(it.products).thenReturn(listOf(BASIC_SUBSCRIPTION)) @@ -282,14 +281,14 @@ class RealPlayBillingManagerTest { replacementMode = SubscriptionReplacementMode.DEFERRED, ) - assertEquals(Canceled, awaitItem()) + assertEquals(PurchaseState.Failure("Missing product details"), awaitItem()) } billingClientAdapter.verifyLaunchSubscriptionUpdateNotInvoked() } @Test - fun `when launchSubscriptionUpdate fails then emits canceled state`() = runTest { + fun `when launchSubscriptionUpdate fails then emits failure state`() = runTest { // Set up purchase history so getCurrentPurchaseToken() returns a valid token val mockPurchase: PurchaseHistoryRecord = mock { whenever(it.products).thenReturn(listOf(BASIC_SUBSCRIPTION)) @@ -321,7 +320,7 @@ class RealPlayBillingManagerTest { replacementMode = replacementMode, ) - assertEquals(Canceled, awaitItem()) + assertEquals(PurchaseState.Failure("SERVICE_UNAVAILABLE"), awaitItem()) } billingClientAdapter.verifyLaunchSubscriptionUpdateInvoked( @@ -334,7 +333,7 @@ class RealPlayBillingManagerTest { } @Test - fun `when launchSubscriptionUpdate called with empty purchase token then emits canceled state`() = runTest { + fun `when launchSubscriptionUpdate called with empty purchase token then emits failure state`() = runTest { // Test with empty purchase token to simulate no valid token scenario billingClientAdapter.subscriptionsPurchaseHistory = emptyList() @@ -356,11 +355,24 @@ class RealPlayBillingManagerTest { replacementMode = SubscriptionReplacementMode.DEFERRED, ) - assertEquals(Canceled, awaitItem()) + assertEquals(PurchaseState.Failure("empty old purchase token"), awaitItem()) } billingClientAdapter.verifyLaunchSubscriptionUpdateNotInvoked() } + + @Test + fun `when purchase update fails then emits failure state`() = runTest { + processLifecycleOwner.currentState = RESUMED + + subject.purchaseState.test { + expectNoEvents() + + billingClientAdapter.purchasesListener?.invoke(PurchasesUpdateResult.Failure("BILLING_UNAVAILABLE")) + + assertEquals(PurchaseState.Failure("BILLING_UNAVAILABLE"), awaitItem()) + } + } } class FakeBillingClientAdapter : BillingClientAdapter {