diff --git a/app/src/test/java/com/duckduckgo/subscriptions/impl/wideevents/SubscriptionSwitchWideEventTest.kt b/app/src/test/java/com/duckduckgo/subscriptions/impl/wideevents/SubscriptionSwitchWideEventTest.kt new file mode 100644 index 000000000000..753d073f9c91 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/subscriptions/impl/wideevents/SubscriptionSwitchWideEventTest.kt @@ -0,0 +1,362 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.subscriptions.impl.wideevents + +import android.annotation.SuppressLint +import com.duckduckgo.app.statistics.wideevents.CleanupPolicy +import com.duckduckgo.app.statistics.wideevents.FlowStatus +import com.duckduckgo.app.statistics.wideevents.WideEventClient +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.subscriptions.api.SubscriptionStatus +import com.duckduckgo.subscriptions.impl.PrivacyProFeature +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever + +class SubscriptionSwitchWideEventTest { + + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val wideEventClient: WideEventClient = org.mockito.kotlin.mock() + + @SuppressLint("DenyListedApi") + private val privacyProFeature: PrivacyProFeature = + FakeFeatureToggleFactory + .create(PrivacyProFeature::class.java) + .apply { sendSubscriptionSwitchWideEvent().setRawStoredState(Toggle.State(true)) } + + private lateinit var subscriptionSwitchWideEvent: SubscriptionSwitchWideEventImpl + + @Before + fun setup() { + subscriptionSwitchWideEvent = SubscriptionSwitchWideEventImpl( + wideEventClient = wideEventClient, + privacyProFeature = { privacyProFeature }, + dispatchers = coroutineRule.testDispatcherProvider, + appCoroutineScope = coroutineRule.testScope, + ) + } + + @Test + fun `onSwitchFlowStarted starts flow with correct metadata`() = runTest { + whenever(wideEventClient.flowStart(any(), any(), any(), any())).thenReturn(Result.success(123L)) + + subscriptionSwitchWideEvent.onSwitchFlowStarted( + context = "subscription_settings", + fromPlan = "ddg-privacy-pro-monthly-renews-us", + toPlan = "ddg-privacy-pro-yearly-renews-us", + ) + + // fromPlan is monthly, so switchType should be computed as "upgrade" + verify(wideEventClient).flowStart( + name = "subscription-switch", + flowEntryPoint = "subscription_settings", + metadata = mapOf( + "from_plan" to "ddg-privacy-pro-monthly-renews-us", + "to_plan" to "ddg-privacy-pro-yearly-renews-us", + "switch_type" to "upgrade", + ), + cleanupPolicy = CleanupPolicy.OnProcessStart(ignoreIfIntervalTimeoutPresent = true), + ) + } + + @Test + fun `onCurrentSubscriptionValidated sends successful step`() = runTest { + whenever(wideEventClient.getFlowIds(any())) + .thenReturn(Result.success(listOf(123L))) + + subscriptionSwitchWideEvent.onCurrentSubscriptionValidated() + + verify(wideEventClient).flowStep( + wideEventId = 123L, + stepName = "validate_current_subscription", + success = true, + ) + } + + @Test + fun `onValidationFailure sends failure step and finishes flow with error`() = runTest { + whenever(wideEventClient.getFlowIds(any())) + .thenReturn(Result.success(listOf(123L))) + + subscriptionSwitchWideEvent.onValidationFailure("User not signed in") + + verify(wideEventClient).flowStep( + wideEventId = 123L, + stepName = "validate_current_subscription", + success = false, + ) + verify(wideEventClient).flowFinish( + wideEventId = 123L, + status = FlowStatus.Failure("User not signed in"), + metadata = emptyMap(), + ) + } + + @Test + fun `onTargetPlanRetrieved sends successful step`() = runTest { + whenever(wideEventClient.getFlowIds(any())) + .thenReturn(Result.success(listOf(123L))) + + subscriptionSwitchWideEvent.onTargetPlanRetrieved() + + verify(wideEventClient).flowStep( + wideEventId = 123L, + stepName = "retrieve_target_plan", + success = true, + ) + } + + @Test + fun `onTargetPlanRetrievalFailure sends failure step and finishes flow`() = runTest { + whenever(wideEventClient.getFlowIds(any())) + .thenReturn(Result.success(listOf(123L))) + + subscriptionSwitchWideEvent.onTargetPlanRetrievalFailure() + + verify(wideEventClient).flowStep( + wideEventId = 123L, + stepName = "retrieve_target_plan", + success = false, + ) + verify(wideEventClient).flowFinish( + wideEventId = 123L, + status = FlowStatus.Failure("Target plan not found"), + metadata = emptyMap(), + ) + } + + @Test + fun `onBillingFlowInitSuccess sends successful step`() = runTest { + whenever(wideEventClient.getFlowIds(any())) + .thenReturn(Result.success(listOf(123L))) + + subscriptionSwitchWideEvent.onBillingFlowInitSuccess() + + verify(wideEventClient).flowStep( + wideEventId = 123L, + stepName = "billing_flow_init", + success = true, + ) + } + + @Test + fun `onBillingFlowInitFailure sends failure step and finishes flow`() = runTest { + whenever(wideEventClient.getFlowIds(any())) + .thenReturn(Result.success(listOf(123L))) + + subscriptionSwitchWideEvent.onBillingFlowInitFailure("Missing product details") + + verify(wideEventClient).flowStep( + wideEventId = 123L, + stepName = "billing_flow_init", + success = false, + ) + verify(wideEventClient).flowFinish( + wideEventId = 123L, + status = FlowStatus.Failure("Missing product details"), + metadata = emptyMap(), + ) + } + + @Test + fun `onUserCancelled finishes flow with cancelled status`() = runTest { + whenever(wideEventClient.getFlowIds(any())) + .thenReturn(Result.success(listOf(123L))) + + subscriptionSwitchWideEvent.onUserCancelled() + + verify(wideEventClient).flowFinish( + wideEventId = 123L, + status = FlowStatus.Cancelled, + metadata = emptyMap(), + ) + } + + @Test + fun `onPlayBillingSwitchSuccess starts interval and sends flowStep`() = runTest { + whenever(wideEventClient.getFlowIds(any())) + .thenReturn(Result.success(listOf(444L))) + + subscriptionSwitchWideEvent.onPlayBillingSwitchSuccess() + + verify(wideEventClient).intervalStart( + wideEventId = eq(444L), + key = eq("activation_latency_ms_bucketed"), + timeout = any(), + ) + verify(wideEventClient).flowStep( + wideEventId = eq(444L), + stepName = eq("billing_flow_switch"), + success = eq(true), + metadata = eq(emptyMap()), + ) + } + + @Test + fun `onSwitchConfirmationSuccess ends interval, sends step, and finishes flow with success`() = runTest { + whenever(wideEventClient.getFlowIds(any())) + .thenReturn(Result.success(listOf(123L))) + + subscriptionSwitchWideEvent.onSwitchConfirmationSuccess() + + verify(wideEventClient).intervalEnd( + wideEventId = 123L, + key = "activation_latency_ms_bucketed", + ) + verify(wideEventClient).flowStep( + wideEventId = 123L, + stepName = "confirm_switch", + success = true, + ) + verify(wideEventClient).flowFinish( + wideEventId = 123L, + status = FlowStatus.Success, + metadata = emptyMap(), + ) + } + + @Test + fun `onSubscriptionUpdated from WAITING to ACTIVE finishes flow with success`() = runTest { + whenever(wideEventClient.getFlowIds(any())) + .thenReturn(Result.success(listOf(123L))) + + subscriptionSwitchWideEvent.onSubscriptionUpdated( + oldStatus = SubscriptionStatus.WAITING, + newStatus = SubscriptionStatus.AUTO_RENEWABLE, + ) + + verify(wideEventClient).intervalEnd( + wideEventId = 123L, + key = "activation_latency_ms_bucketed", + ) + verify(wideEventClient).flowFinish( + wideEventId = 123L, + status = FlowStatus.Success, + metadata = emptyMap(), + ) + } + + @Test + fun `onSubscriptionUpdated does not finish flow if not transitioning from WAITING to ACTIVE`() = runTest { + whenever(wideEventClient.getFlowIds(any())) + .thenReturn(Result.success(listOf(123L))) + + // Test various status transitions that should NOT finish the flow + subscriptionSwitchWideEvent.onSubscriptionUpdated( + oldStatus = SubscriptionStatus.AUTO_RENEWABLE, + newStatus = SubscriptionStatus.GRACE_PERIOD, + ) + + subscriptionSwitchWideEvent.onSubscriptionUpdated( + oldStatus = SubscriptionStatus.WAITING, + newStatus = SubscriptionStatus.INACTIVE, + ) + + subscriptionSwitchWideEvent.onSubscriptionUpdated( + oldStatus = SubscriptionStatus.UNKNOWN, + newStatus = SubscriptionStatus.AUTO_RENEWABLE, + ) + + verify(wideEventClient, never()).intervalEnd(any(), any()) + verify(wideEventClient, never()).flowFinish(any(), any(), any()) + } + + @Test + fun `onUIRefreshed sends step without success parameter`() = runTest { + whenever(wideEventClient.getFlowIds(any())) + .thenReturn(Result.success(listOf(123L))) + + subscriptionSwitchWideEvent.onUIRefreshed() + + verify(wideEventClient).flowStep( + wideEventId = 123L, + stepName = "ui_refresh", + ) + } + + @Test + fun `onSwitchFailed finishes flow with failure status and error`() = runTest { + whenever(wideEventClient.getFlowIds(any())) + .thenReturn(Result.success(listOf(123L))) + + subscriptionSwitchWideEvent.onSwitchFailed("Unexpected error") + + verify(wideEventClient).flowFinish( + wideEventId = 123L, + status = FlowStatus.Failure("Unexpected error"), + metadata = emptyMap(), + ) + } + + @SuppressLint("DenyListedApi") + @Test + fun `when feature disabled then no events are sent`() = runTest { + privacyProFeature.sendSubscriptionSwitchWideEvent().setRawStoredState(Toggle.State(false)) + + // Mock getFlowIds to return empty list when methods try to retrieve flow ID + whenever(wideEventClient.getFlowIds(any())) + .thenReturn(Result.success(emptyList())) + + subscriptionSwitchWideEvent.onSwitchFlowStarted( + context = "subscription_settings", + fromPlan = "ddg-privacy-pro-monthly-renews-us", + toPlan = "ddg-privacy-pro-yearly-renews-us", + ) + subscriptionSwitchWideEvent.onCurrentSubscriptionValidated() + subscriptionSwitchWideEvent.onTargetPlanRetrieved() + subscriptionSwitchWideEvent.onBillingFlowInitSuccess() + subscriptionSwitchWideEvent.onPlayBillingSwitchSuccess() + subscriptionSwitchWideEvent.onSwitchConfirmationSuccess() + + verify(wideEventClient, never()).flowStart(any(), any(), any(), any()) + verify(wideEventClient, never()).flowStep(any(), any(), any(), any()) + verify(wideEventClient, never()).flowFinish(any(), any(), any()) + } + + @Test + fun `onSwitchConfirmationSuccess clears cachedFlowId after finishing`() = runTest { + whenever(wideEventClient.getFlowIds(any())) + .thenReturn(Result.success(listOf(100L))) + + subscriptionSwitchWideEvent.onSwitchConfirmationSuccess() + + verify(wideEventClient).flowFinish( + wideEventId = 100L, + status = FlowStatus.Success, + metadata = emptyMap(), + ) + + // Reset and verify that cachedFlowId was cleared + org.mockito.kotlin.reset(wideEventClient) + whenever(wideEventClient.getFlowIds(any())).thenReturn(Result.success(emptyList())) + + subscriptionSwitchWideEvent.onSwitchConfirmationSuccess() + verify(wideEventClient).getFlowIds("subscription-switch") + verifyNoMoreInteractions(wideEventClient) + } +} diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt index 86edebee4e1a..4939dbedb4b4 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/RealSubscriptions.kt @@ -255,6 +255,9 @@ interface PrivacyProFeature { @Toggle.DefaultValue(defaultValue = DefaultFeatureValue.TRUE) fun sendAuthTokenRefreshWideEvent(): Toggle + @Toggle.DefaultValue(defaultValue = DefaultFeatureValue.TRUE) + fun sendSubscriptionSwitchWideEvent(): Toggle + @Toggle.DefaultValue(defaultValue = DefaultFeatureValue.TRUE) fun useSubscriptionSupport(): Toggle 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 14b936f9c679..4a0ef5c03942 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 @@ -76,6 +76,7 @@ import com.duckduckgo.subscriptions.impl.services.ValidateTokenResponse import com.duckduckgo.subscriptions.impl.services.toEntitlements import com.duckduckgo.subscriptions.impl.wideevents.AuthTokenRefreshWideEvent import com.duckduckgo.subscriptions.impl.wideevents.SubscriptionPurchaseWideEvent +import com.duckduckgo.subscriptions.impl.wideevents.SubscriptionSwitchWideEvent import com.squareup.anvil.annotations.ContributesBinding import com.squareup.moshi.JsonDataException import com.squareup.moshi.JsonEncodingException @@ -252,6 +253,7 @@ interface SubscriptionsManager { * @param planId The new plan ID to switch to * @param offerId The offer ID for the new plan (optional) * @param replacementMode The replacement mode for the subscription switch + * @param origin The entry point where the switch was initiated (e.g., "subscription_settings", "dev_settings") * */ suspend fun switchSubscriptionPlan( @@ -259,6 +261,7 @@ interface SubscriptionsManager { planId: String, offerId: String? = null, replacementMode: SubscriptionReplacementMode, + origin: String? = null, ) /** @@ -295,6 +298,7 @@ class RealSubscriptionsManager @Inject constructor( private val backgroundTokenRefresh: BackgroundTokenRefresh, private val subscriptionPurchaseWideEvent: SubscriptionPurchaseWideEvent, private val tokenRefreshWideEvent: AuthTokenRefreshWideEvent, + private val subscriptionSwitchWideEvent: SubscriptionSwitchWideEvent, ) : SubscriptionsManager { private val adapter = Moshi.Builder().build().adapter(ResponseError::class.java) @@ -371,6 +375,7 @@ class RealSubscriptionsManager @Inject constructor( when (it) { is PurchaseState.Purchased -> { subscriptionPurchaseWideEvent.onBillingFlowPurchaseSuccess() + subscriptionSwitchWideEvent.onPlayBillingSwitchSuccess() checkPurchase(it.packageName, it.purchaseToken) } is PurchaseState.Canceled -> { @@ -495,34 +500,49 @@ class RealSubscriptionsManager @Inject constructor( planId: String, offerId: String?, replacementMode: SubscriptionReplacementMode, + origin: String?, ) = withContext(dispatcherProvider.io()) { try { - if (!isSignedIn()) { - logcat { "Subs: Cannot switch plan - user not signed in" } - _currentPurchaseState.emit(CurrentPurchase.Failure("User not signed in for switch")) - return@withContext - } - val currentSubscription = authRepository.getSubscription() if (currentSubscription == null || !currentSubscription.isActive()) { - logcat { "Subs: Cannot switch plan - no active subscription found" } _currentPurchaseState.emit(CurrentPurchase.Failure("No active subscription found for switch")) return@withContext } + // Start wide event tracking + subscriptionSwitchWideEvent.onSwitchFlowStarted( + context = origin, + fromPlan = currentSubscription.productId, + toPlan = planId, + ) + + if (!isSignedIn()) { + val errorMessage = "User not signed in for switch" + logcat { "Subs: Cannot switch plan - $errorMessage" } + subscriptionSwitchWideEvent.onValidationFailure(errorMessage) + _currentPurchaseState.emit(CurrentPurchase.Failure(errorMessage)) + return@withContext + } + + subscriptionSwitchWideEvent.onCurrentSubscriptionValidated() + val currentPurchaseToken = playBillingManager.getLatestPurchaseToken() if (currentPurchaseToken == null) { - logcat { "Subs: Cannot switch plan - no current purchase token found" } - _currentPurchaseState.emit(CurrentPurchase.Failure("No current purchase token found for switch")) + val errorMessage = "No current purchase token found for switch" + logcat { "Subs: Cannot switch plan - $errorMessage" } + subscriptionSwitchWideEvent.onSwitchFailed(errorMessage) + _currentPurchaseState.emit(CurrentPurchase.Failure(errorMessage)) return@withContext } // Get account details for external ID val account = authRepository.getAccount() if (account == null) { - logcat { "Subs: Cannot switch plan - no account found" } - _currentPurchaseState.emit(CurrentPurchase.Failure("No account found for switch")) + val errorMessage = "No account found for switch" + logcat { "Subs: Cannot switch plan - $errorMessage" } + subscriptionSwitchWideEvent.onSwitchFailed(errorMessage) + _currentPurchaseState.emit(CurrentPurchase.Failure(errorMessage)) return@withContext } @@ -530,11 +550,15 @@ class RealSubscriptionsManager @Inject constructor( val availableOffers = getSubscriptionOffer() val targetOffer = availableOffers.find { it.planId == planId && it.offerId == offerId } if (targetOffer == null) { - logcat { "Subs: Cannot switch plan - target plan not found: $planId" } - _currentPurchaseState.emit(CurrentPurchase.Failure("Target plan not found: $planId for switch")) + val errorMessage = "Target plan not found: $planId" + logcat { "Subs: Cannot switch plan - $errorMessage" } + subscriptionSwitchWideEvent.onTargetPlanRetrievalFailure() + _currentPurchaseState.emit(CurrentPurchase.Failure(errorMessage)) return@withContext } + subscriptionSwitchWideEvent.onTargetPlanRetrieved() + // Launch Google Play billing flow for subscription update logcat { "Subs: Launching subscription update flow for plan: $planId" } @@ -550,8 +574,10 @@ class RealSubscriptionsManager @Inject constructor( ) } } catch (e: Exception) { - logcat(ERROR) { "Subs: Failed to switch subscription plan: ${e.asLog()}" } - _currentPurchaseState.emit(CurrentPurchase.Failure("Failed to switch subscription plan: ${e.message}")) + val error = extractError(e) + logcat(ERROR) { "Subs: Failed to switch subscription plan: $error" } + _currentPurchaseState.emit(CurrentPurchase.Failure("Failed to switch subscription plan: $error")) + subscriptionSwitchWideEvent.onSwitchFailed(javaClass.simpleName) } } @@ -693,6 +719,8 @@ class RealSubscriptionsManager @Inject constructor( emitEntitlementsValues() _currentPurchaseState.emit(CurrentPurchase.Success) authRepository.registerLocalPurchasedAt() + + subscriptionSwitchWideEvent.onSwitchConfirmationSuccess() subscriptionPurchaseWideEvent.onPurchaseConfirmationSuccess() } else { handlePurchaseFailed() @@ -890,6 +918,7 @@ class RealSubscriptionsManager @Inject constructor( ) subscriptionPurchaseWideEvent.onSubscriptionUpdated(oldStatus = oldStatus, newStatus = subscription.status.toStatus()) + subscriptionSwitchWideEvent.onSubscriptionUpdated(oldStatus = oldStatus, newStatus = subscription.status.toStatus()) _subscriptionStatus.emit(subscription.status.toStatus()) } @@ -1390,7 +1419,6 @@ data class PricingPhase( val priceCurrency: Currency, val formattedPrice: String, val billingPeriod: String, - ) { internal fun getBillingPeriodInDays(): Int? { return try { 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 0141d899b9eb..f895368b3908 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 @@ -42,6 +42,7 @@ import com.duckduckgo.subscriptions.impl.billing.PurchasesUpdateResult.PurchaseP import com.duckduckgo.subscriptions.impl.billing.PurchasesUpdateResult.UserCancelled import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.wideevents.SubscriptionPurchaseWideEvent +import com.duckduckgo.subscriptions.impl.wideevents.SubscriptionSwitchWideEvent import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesMultibinding import dagger.SingleInstanceIn @@ -111,6 +112,7 @@ class RealPlayBillingManager @Inject constructor( private val billingClient: BillingClientAdapter, private val dispatcherProvider: DispatcherProvider, private val subscriptionPurchaseWideEvent: SubscriptionPurchaseWideEvent, + private val subscriptionSwitchWideEvent: SubscriptionSwitchWideEvent, ) : PlayBillingManager, MainProcessLifecycleObserver { private val connectionMutex = Mutex() @@ -265,7 +267,9 @@ class RealPlayBillingManager @Inject constructor( logcat { "Billing: Using provided old purchase token: ${oldPurchaseToken.take(10)}..." } if (oldPurchaseToken.isEmpty()) { - logcat(logcat.LogPriority.ERROR) { "Billing: Cannot launch subscription update - empty purchase token" } + val errorMessage = "empty old purchase token" + logcat { "Billing: $errorMessage" } + subscriptionSwitchWideEvent.onBillingFlowInitFailure(errorMessage) _purchaseState.emit(Canceled) return@withContext } @@ -278,9 +282,9 @@ class RealPlayBillingManager @Inject constructor( ?.offerToken if (productDetails == null || offerToken == null) { - logcat(logcat.LogPriority.ERROR) { - "Billing: Cannot launch subscription update - productDetails: ${productDetails != null}, offerToken: ${offerToken != null}" - } + val errorMessage = "Missing product details" + logcat { "Billing: $errorMessage" } + subscriptionSwitchWideEvent.onBillingFlowInitFailure(errorMessage) _purchaseState.emit(Canceled) return@withContext } @@ -296,11 +300,13 @@ class RealPlayBillingManager @Inject constructor( when (launchBillingFlowResult) { LaunchBillingFlowResult.Success -> { + subscriptionSwitchWideEvent.onBillingFlowInitSuccess() _purchaseState.emit(InProgress) billingFlowInProgress = true } is LaunchBillingFlowResult.Failure -> { + subscriptionSwitchWideEvent.onBillingFlowInitFailure(launchBillingFlowResult.error.name) _purchaseState.emit(Canceled) } } @@ -321,12 +327,14 @@ 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) } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/switch_plan/SwitchPlanBottomSheetDialog.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/switch_plan/SwitchPlanBottomSheetDialog.kt index e918f92882f6..507b2704884a 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/switch_plan/SwitchPlanBottomSheetDialog.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/switch_plan/SwitchPlanBottomSheetDialog.kt @@ -35,6 +35,7 @@ import com.duckduckgo.subscriptions.impl.SubscriptionsManager import com.duckduckgo.subscriptions.impl.billing.SubscriptionReplacementMode import com.duckduckgo.subscriptions.impl.databinding.BottomSheetSwitchPlanBinding import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.SwitchPlanType +import com.duckduckgo.subscriptions.impl.wideevents.SubscriptionSwitchWideEvent import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.shape.CornerFamily @@ -54,6 +55,7 @@ class SwitchPlanBottomSheetDialog @AssistedInject constructor( @Assisted private val onSwitchSuccess: () -> Unit, private val subscriptionsManager: SubscriptionsManager, private val dispatcherProvider: DispatcherProvider, + private val subscriptionSwitchWideEvent: SubscriptionSwitchWideEvent, ) : BottomSheetDialog(context) { private val binding: BottomSheetSwitchPlanBinding = BottomSheetSwitchPlanBinding.inflate(LayoutInflater.from(context)) @@ -167,17 +169,10 @@ class SwitchPlanBottomSheetDialog @AssistedInject constructor( when (it) { is CurrentPurchase.Success -> { logcat { "Switch flow: Successfully switched plans" } + subscriptionSwitchWideEvent.onUIRefreshed() onSwitchSuccess.invoke() } - is CurrentPurchase.Failure -> { - logcat { "Switch flow: Failed to switch plans. Error: ${it.message}" } - } - - is CurrentPurchase.Canceled -> { - logcat { "Switch flow: Canceled switch plans" } - } - else -> {} } } @@ -210,6 +205,7 @@ class SwitchPlanBottomSheetDialog @AssistedInject constructor( planId = targetPlanId, offerId = null, replacementMode = SubscriptionReplacementMode.WITHOUT_PRORATION, + origin = "subscription_settings", ) } } catch (e: Exception) { diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/wideevents/SubscriptionSwitchWideEvent.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/wideevents/SubscriptionSwitchWideEvent.kt new file mode 100644 index 000000000000..4350b956ec30 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/wideevents/SubscriptionSwitchWideEvent.kt @@ -0,0 +1,319 @@ +/* + * Copyright (c) 2025 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.subscriptions.impl.wideevents + +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.statistics.wideevents.CleanupPolicy.OnProcessStart +import com.duckduckgo.app.statistics.wideevents.FlowStatus +import com.duckduckgo.app.statistics.wideevents.WideEventClient +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.subscriptions.api.SubscriptionStatus +import com.duckduckgo.subscriptions.impl.PrivacyProFeature +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_ROW +import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_US +import com.duckduckgo.subscriptions.impl.repository.isActive +import com.squareup.anvil.annotations.ContributesBinding +import dagger.Lazy +import dagger.SingleInstanceIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.time.Duration +import javax.inject.Inject + +interface SubscriptionSwitchWideEvent { + suspend fun onSwitchFlowStarted( + context: String?, + fromPlan: String, + toPlan: String, + ) + + suspend fun onCurrentSubscriptionValidated() + + suspend fun onValidationFailure(error: String) + + suspend fun onTargetPlanRetrieved() + + suspend fun onTargetPlanRetrievalFailure() + + suspend fun onBillingFlowInitSuccess() + + suspend fun onBillingFlowInitFailure(error: String) + + suspend fun onUserCancelled() + + suspend fun onPlayBillingSwitchSuccess() + + suspend fun onSwitchConfirmationSuccess() + + suspend fun onSubscriptionUpdated( + oldStatus: SubscriptionStatus?, + newStatus: SubscriptionStatus?, + ) + + fun onUIRefreshed() + + suspend fun onSwitchFailed(error: String) +} + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class) +class SubscriptionSwitchWideEventImpl @Inject constructor( + private val wideEventClient: WideEventClient, + private val privacyProFeature: Lazy, + private val dispatchers: DispatcherProvider, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, +) : SubscriptionSwitchWideEvent { + + private var cachedFlowId: Long? = null + + override suspend fun onSwitchFlowStarted( + context: String?, + fromPlan: String, + toPlan: String, + ) { + if (!isFeatureEnabled()) return + + val isUpgrade = fromPlan in listOf(MONTHLY_PLAN_US, MONTHLY_PLAN_ROW) + val switchType = if (isUpgrade) SWITCH_TYPE_UPGRADE else SWITCH_TYPE_DOWNGRADE + + cachedFlowId = wideEventClient + .flowStart( + name = SUBSCRIPTION_SWITCH_FEATURE_NAME, + flowEntryPoint = context, + metadata = mapOf( + KEY_FROM_PLAN to fromPlan, + KEY_TO_PLAN to toPlan, + KEY_SWITCH_TYPE to switchType, + ), + cleanupPolicy = OnProcessStart(ignoreIfIntervalTimeoutPresent = true), + ) + .getOrNull() + } + + override suspend fun onCurrentSubscriptionValidated() { + if (!isFeatureEnabled()) return + + getCurrentWideEventId()?.let { wideEventId -> + wideEventClient.flowStep( + wideEventId = wideEventId, + stepName = STEP_VALIDATE_CURRENT_SUBSCRIPTION, + success = true, + ) + } + } + + override suspend fun onValidationFailure(error: String) { + if (!isFeatureEnabled()) return + + getCurrentWideEventId()?.let { wideEventId -> + wideEventClient.flowStep( + wideEventId = wideEventId, + stepName = STEP_VALIDATE_CURRENT_SUBSCRIPTION, + success = false, + ) + wideEventClient.flowFinish( + wideEventId = wideEventId, + status = FlowStatus.Failure(reason = error), + ) + cachedFlowId = null + } + } + + override suspend fun onTargetPlanRetrieved() { + if (!isFeatureEnabled()) return + + getCurrentWideEventId()?.let { wideEventId -> + wideEventClient.flowStep( + wideEventId = wideEventId, + stepName = STEP_RETRIEVE_TARGET_PLAN, + success = true, + ) + } + } + + override suspend fun onTargetPlanRetrievalFailure() { + if (!isFeatureEnabled()) return + + getCurrentWideEventId()?.let { wideEventId -> + wideEventClient.flowStep( + wideEventId = wideEventId, + stepName = STEP_RETRIEVE_TARGET_PLAN, + success = false, + ) + wideEventClient.flowFinish( + wideEventId = wideEventId, + status = FlowStatus.Failure(reason = "Target plan not found"), + ) + cachedFlowId = null + } + } + + override suspend fun onBillingFlowInitSuccess() { + if (!isFeatureEnabled()) return + + getCurrentWideEventId()?.let { wideEventId -> + wideEventClient.flowStep( + wideEventId = wideEventId, + stepName = STEP_BILLING_FLOW_INIT, + success = true, + ) + } + } + + override suspend fun onBillingFlowInitFailure(error: String) { + if (!isFeatureEnabled()) return + + getCurrentWideEventId()?.let { wideEventId -> + wideEventClient.flowStep( + wideEventId = wideEventId, + stepName = STEP_BILLING_FLOW_INIT, + success = false, + ) + wideEventClient.flowFinish( + wideEventId = wideEventId, + status = FlowStatus.Failure(reason = error), + ) + cachedFlowId = null + } + } + + override suspend fun onUserCancelled() { + if (!isFeatureEnabled()) return + + getCurrentWideEventId()?.let { wideEventId -> + wideEventClient.flowFinish( + wideEventId = wideEventId, + status = FlowStatus.Cancelled, + ) + cachedFlowId = null + } + } + + override suspend fun onPlayBillingSwitchSuccess() { + if (!isFeatureEnabled()) return + + getCurrentWideEventId()?.let { wideEventId -> + wideEventClient.intervalStart( + wideEventId = wideEventId, + key = KEY_ACTIVATION_LATENCY, + timeout = Duration.ofMinutes(10), + ) + wideEventClient.flowStep( + wideEventId = wideEventId, + stepName = STEP_BILLING_FLOW_SWITCH, + success = true, + ) + } + } + + override suspend fun onSwitchConfirmationSuccess() { + if (!isFeatureEnabled()) return + + getCurrentWideEventId()?.let { wideEventId -> + wideEventClient.intervalEnd( + wideEventId = wideEventId, + key = KEY_ACTIVATION_LATENCY, + ) + wideEventClient.flowStep( + wideEventId = wideEventId, + stepName = STEP_CONFIRM_SWITCH, + success = true, + ) + wideEventClient.flowFinish( + wideEventId = wideEventId, + status = FlowStatus.Success, + ) + cachedFlowId = null + } + } + + override suspend fun onSubscriptionUpdated( + oldStatus: SubscriptionStatus?, + newStatus: SubscriptionStatus?, + ) { + if (!isFeatureEnabled()) return + + if (oldStatus == SubscriptionStatus.WAITING && newStatus?.isActive() == true) { + val wideEventId = getCurrentWideEventId() ?: return + wideEventClient.intervalEnd(wideEventId = wideEventId, key = KEY_ACTIVATION_LATENCY) + wideEventClient.flowFinish(wideEventId = wideEventId, status = FlowStatus.Success) + cachedFlowId = null + } + } + + override fun onUIRefreshed() { + appCoroutineScope.launch(dispatchers.io()) { + if (!isFeatureEnabled()) return@launch + + getCurrentWideEventId()?.let { wideEventId -> + wideEventClient.flowStep( + wideEventId = wideEventId, + stepName = STEP_UI_REFRESH, + ) + } + } + } + + override suspend fun onSwitchFailed(error: String) { + if (!isFeatureEnabled()) return + + getCurrentWideEventId()?.let { wideEventId -> + wideEventClient.flowFinish( + wideEventId = wideEventId, + status = FlowStatus.Failure(reason = error), + ) + cachedFlowId = null + } + } + + private suspend fun isFeatureEnabled(): Boolean = withContext(dispatchers.io()) { + privacyProFeature.get().sendSubscriptionSwitchWideEvent().isEnabled() + } + + private suspend fun getCurrentWideEventId(): Long? { + if (cachedFlowId == null) { + cachedFlowId = wideEventClient + .getFlowIds(SUBSCRIPTION_SWITCH_FEATURE_NAME) + .getOrNull() + ?.lastOrNull() + } + + return cachedFlowId + } + + private companion object { + const val SUBSCRIPTION_SWITCH_FEATURE_NAME = "subscription-switch" + const val KEY_FROM_PLAN = "from_plan" + const val KEY_TO_PLAN = "to_plan" + const val KEY_SWITCH_TYPE = "switch_type" + const val KEY_ACTIVATION_LATENCY = "activation_latency_ms_bucketed" + + // Switch types + const val SWITCH_TYPE_UPGRADE = "upgrade" + const val SWITCH_TYPE_DOWNGRADE = "downgrade" + + // Steps + const val STEP_VALIDATE_CURRENT_SUBSCRIPTION = "validate_current_subscription" + const val STEP_RETRIEVE_TARGET_PLAN = "retrieve_target_plan" + const val STEP_BILLING_FLOW_INIT = "billing_flow_init" + const val STEP_BILLING_FLOW_SWITCH = "billing_flow_switch" + const val STEP_CONFIRM_SWITCH = "confirm_switch" + const val STEP_UI_REFRESH = "ui_refresh" + } +} 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 1538448891de..62f70d1fbe49 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 @@ -65,6 +65,7 @@ import com.duckduckgo.subscriptions.impl.services.ValidateTokenResponse import com.duckduckgo.subscriptions.impl.store.SubscriptionsDataStore import com.duckduckgo.subscriptions.impl.wideevents.AuthTokenRefreshWideEvent import com.duckduckgo.subscriptions.impl.wideevents.SubscriptionPurchaseWideEvent +import com.duckduckgo.subscriptions.impl.wideevents.SubscriptionSwitchWideEvent import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf @@ -123,6 +124,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { private val pixelSender: SubscriptionPixelSender = mock() private val subscriptionPurchaseWideEvent: SubscriptionPurchaseWideEvent = mock() private val tokenRefreshWideEvent: AuthTokenRefreshWideEvent = mock() + private val subscriptionSwitchWideEvent: SubscriptionSwitchWideEvent = mock() @SuppressLint("DenyListedApi") private val privacyProFeature: PrivacyProFeature = FakeFeatureToggleFactory.create(PrivacyProFeature::class.java) @@ -138,6 +140,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { fun before() = runTest { whenever(emailManager.getToken()).thenReturn(null) whenever(context.packageName).thenReturn("packageName") + whenever(playBillingManager.purchaseState).thenReturn(flowOf()) subscriptionsManager = RealSubscriptionsManager( authService, subscriptionsService, @@ -156,6 +159,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { backgroundTokenRefresh, subscriptionPurchaseWideEvent, tokenRefreshWideEvent, + subscriptionSwitchWideEvent, ) } @@ -581,6 +585,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { @Test fun whenSubscribedToSubscriptionStatusThenEmit() = runTest { + whenever(playBillingManager.purchaseState).thenReturn(flowOf()) val manager = RealSubscriptionsManager( authService, subscriptionsService, @@ -599,6 +604,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { backgroundTokenRefresh, subscriptionPurchaseWideEvent, tokenRefreshWideEvent, + subscriptionSwitchWideEvent, ) manager.subscriptionStatus.test { @@ -611,6 +617,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { fun whenSubscribedToSubscriptionStatusAndSubscriptionExistsThenEmit() = runTest { givenUserIsSignedIn() givenSubscriptionExists() + whenever(playBillingManager.purchaseState).thenReturn(flowOf()) val manager = RealSubscriptionsManager( authService, subscriptionsService, @@ -629,6 +636,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { backgroundTokenRefresh, subscriptionPurchaseWideEvent, tokenRefreshWideEvent, + subscriptionSwitchWideEvent, ) manager.subscriptionStatus.test { @@ -664,6 +672,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { backgroundTokenRefresh, subscriptionPurchaseWideEvent, tokenRefreshWideEvent, + subscriptionSwitchWideEvent, ) manager.currentPurchaseState.test { @@ -713,6 +722,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { backgroundTokenRefresh, subscriptionPurchaseWideEvent, tokenRefreshWideEvent, + subscriptionSwitchWideEvent, ) manager.currentPurchaseState.test { @@ -752,6 +762,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { backgroundTokenRefresh, subscriptionPurchaseWideEvent, tokenRefreshWideEvent, + subscriptionSwitchWideEvent, ) manager.currentPurchaseState.test { @@ -1085,6 +1096,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { @Test fun whenSignOutThenCallRepositorySignOut() = runTest { val mockRepo: AuthRepository = mock() + whenever(playBillingManager.purchaseState).thenReturn(flowOf()) val manager = RealSubscriptionsManager( authService, subscriptionsService, @@ -1103,6 +1115,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { backgroundTokenRefresh, subscriptionPurchaseWideEvent, tokenRefreshWideEvent, + subscriptionSwitchWideEvent, ) manager.signOut() verify(mockRepo).setSubscription(null) @@ -1131,6 +1144,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { fun whenSignOutThenEmitUnknown() = runTest { givenUserIsSignedIn() givenSubscriptionExists() + whenever(playBillingManager.purchaseState).thenReturn(flowOf()) val manager = RealSubscriptionsManager( authService, @@ -1150,6 +1164,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { backgroundTokenRefresh, subscriptionPurchaseWideEvent, tokenRefreshWideEvent, + subscriptionSwitchWideEvent, ) manager.subscriptionStatus.test { @@ -1315,6 +1330,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { fun whenCanSupportEncryptionIfCannotThenReturnFalse() = runTest { val authDataStore: SubscriptionsDataStore = FakeSubscriptionsDataStore(supportEncryption = false) val authRepository = RealAuthRepository(authDataStore, coroutineRule.testDispatcherProvider, serpPromo) + whenever(playBillingManager.purchaseState).thenReturn(flowOf()) subscriptionsManager = RealSubscriptionsManager( authService, subscriptionsService, @@ -1333,6 +1349,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { backgroundTokenRefresh, subscriptionPurchaseWideEvent, tokenRefreshWideEvent, + subscriptionSwitchWideEvent, ) assertFalse(subscriptionsManager.canSupportEncryption()) @@ -1832,6 +1849,8 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { @Test fun whenSwitchSubscriptionPlanWithUserNotSignedInThenEmitFailure() = runTest { + givenActiveSubscription() + subscriptionsManager.currentPurchaseState.test { subscriptionsManager.switchSubscriptionPlan( activity = mock(), 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 a7c5aad48a17..ad5fa8d00a60 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 @@ -55,6 +55,7 @@ class RealPlayBillingManagerTest { billingClient = billingClientAdapter, dispatcherProvider = coroutineRule.testDispatcherProvider, subscriptionPurchaseWideEvent = mock(), + subscriptionSwitchWideEvent = mock(), ) @Before diff --git a/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/SwitchSubscriptionView.kt b/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/SwitchSubscriptionView.kt index fca94095b6ac..4d77d04b6f20 100644 --- a/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/SwitchSubscriptionView.kt +++ b/subscriptions/subscriptions-internal/src/main/java/com/duckduckgo/subscriptions/internal/settings/SwitchSubscriptionView.kt @@ -299,6 +299,7 @@ class SwitchSubscriptionView @JvmOverloads constructor( planId = planOption.planId, offerId = planOption.offerId, replacementMode = replacementMode, + origin = "dev_settings", ) } catch (e: Exception) { launch(dispatcherProvider.main()) {