From 8a39fc8a47119efda887da4f7dd0280aaae247d1 Mon Sep 17 00:00:00 2001 From: nalcalag Date: Thu, 20 Nov 2025 20:16:05 -0800 Subject: [PATCH 1/6] Added wide events for switch flow --- .../subscriptions/impl/RealSubscriptions.kt | 3 + .../impl/SubscriptionsManager.kt | 32 +- .../SwitchPlanBottomSheetDialog.kt | 18 + .../wideevents/SubscriptionSwitchWideEvent.kt | 362 ++++++++++++++++++ .../impl/RealSubscriptionsManagerTest.kt | 11 + 5 files changed, 424 insertions(+), 2 deletions(-) create mode 100644 subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/wideevents/SubscriptionSwitchWideEvent.kt 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..ace6baa86c30 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 @@ -295,6 +296,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,10 +373,12 @@ class RealSubscriptionsManager @Inject constructor( when (it) { is PurchaseState.Purchased -> { subscriptionPurchaseWideEvent.onBillingFlowPurchaseSuccess() + subscriptionSwitchWideEvent.onPlayBillingSwitchSuccess() checkPurchase(it.packageName, it.purchaseToken) } is PurchaseState.Canceled -> { _currentPurchaseState.emit(CurrentPurchase.Canceled) + subscriptionSwitchWideEvent.onUserCancelled() if (removeExpiredSubscriptionOnCancelledPurchase) { if (subscriptionStatus().isExpired()) { signOut() @@ -391,6 +395,7 @@ class RealSubscriptionsManager @Inject constructor( } } + override suspend fun canSupportEncryption(): Boolean = authRepository.canSupportEncryption() override suspend fun isFreeTrialEligible(): Boolean { @@ -499,21 +504,34 @@ class RealSubscriptionsManager @Inject constructor( try { if (!isSignedIn()) { logcat { "Subs: Cannot switch plan - user not signed in" } + subscriptionSwitchWideEvent.onValidationFailure("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" } + subscriptionSwitchWideEvent.onValidationFailure("No active subscription found") _currentPurchaseState.emit(CurrentPurchase.Failure("No active subscription found for switch")) return@withContext } + // Start wide event tracking + val isUpgrade = currentSubscription.productId in listOf(MONTHLY_PLAN_US, MONTHLY_PLAN_ROW) + val switchType = if (isUpgrade) "upgrade" else "downgrade" + subscriptionSwitchWideEvent.onSwitchFlowStarted( + fromPlan = currentSubscription.productId, + toPlan = planId, + switchType = switchType, + ) + + subscriptionSwitchWideEvent.onCurrentSubscriptionValidated() + val currentPurchaseToken = playBillingManager.getLatestPurchaseToken() if (currentPurchaseToken == null) { logcat { "Subs: Cannot switch plan - no current purchase token found" } + subscriptionSwitchWideEvent.onSwitchFailed("No current purchase token found") _currentPurchaseState.emit(CurrentPurchase.Failure("No current purchase token found for switch")) return@withContext } @@ -522,6 +540,7 @@ class RealSubscriptionsManager @Inject constructor( val account = authRepository.getAccount() if (account == null) { logcat { "Subs: Cannot switch plan - no account found" } + subscriptionSwitchWideEvent.onSwitchFailed("No account found") _currentPurchaseState.emit(CurrentPurchase.Failure("No account found for switch")) return@withContext } @@ -531,10 +550,13 @@ class RealSubscriptionsManager @Inject constructor( val targetOffer = availableOffers.find { it.planId == planId && it.offerId == offerId } if (targetOffer == null) { logcat { "Subs: Cannot switch plan - target plan not found: $planId" } + subscriptionSwitchWideEvent.onTargetPlanRetrievalFailure() _currentPurchaseState.emit(CurrentPurchase.Failure("Target plan not found: $planId for switch")) return@withContext } + subscriptionSwitchWideEvent.onTargetPlanRetrieved() + // Launch Google Play billing flow for subscription update logcat { "Subs: Launching subscription update flow for plan: $planId" } @@ -549,8 +571,11 @@ class RealSubscriptionsManager @Inject constructor( replacementMode = replacementMode, ) } + + subscriptionSwitchWideEvent.onBillingFlowInitSuccess() } catch (e: Exception) { logcat(ERROR) { "Subs: Failed to switch subscription plan: ${e.asLog()}" } + subscriptionSwitchWideEvent.onBillingFlowInitFailure(e.message ?: "Unknown error") _currentPurchaseState.emit(CurrentPurchase.Failure("Failed to switch subscription plan: ${e.message}")) } } @@ -693,8 +718,11 @@ class RealSubscriptionsManager @Inject constructor( emitEntitlementsValues() _currentPurchaseState.emit(CurrentPurchase.Success) authRepository.registerLocalPurchasedAt() + + subscriptionSwitchWideEvent.onSwitchConfirmed() subscriptionPurchaseWideEvent.onPurchaseConfirmationSuccess() } else { + subscriptionSwitchWideEvent.onSwitchConfirmationFailure("Subscription not active after switch") 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/switch_plan/SwitchPlanBottomSheetDialog.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/switch_plan/SwitchPlanBottomSheetDialog.kt index e918f92882f6..44ffdea2637c 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)) @@ -90,6 +92,11 @@ class SwitchPlanBottomSheetDialog @AssistedInject constructor( override fun onAttachedToWindow() { super.onAttachedToWindow() + // Track that user confirmation dialog was shown + lifecycleOwner.lifecycleScope.launch(dispatcherProvider.io()) { + subscriptionSwitchWideEvent.onUserConfirmationShown() + } + configureViews() observePurchaseState() } @@ -127,6 +134,10 @@ class SwitchPlanBottomSheetDialog @AssistedInject constructor( dismiss() } binding.switchBottomSheetDialogSecondaryButton.setOnClickListener { + // User cancelled the switch by keeping monthly plan + lifecycleOwner.lifecycleScope.launch(dispatcherProvider.io()) { + subscriptionSwitchWideEvent.onUserCancelled() + } dismiss() } } @@ -149,6 +160,10 @@ class SwitchPlanBottomSheetDialog @AssistedInject constructor( ) binding.switchBottomSheetDialogPrimaryButton.setOnClickListener { + // User cancelled the switch by keeping yearly plan + lifecycleOwner.lifecycleScope.launch(dispatcherProvider.io()) { + subscriptionSwitchWideEvent.onUserCancelled() + } dismiss() } binding.switchBottomSheetDialogSecondaryButton.setOnClickListener { @@ -167,6 +182,9 @@ class SwitchPlanBottomSheetDialog @AssistedInject constructor( when (it) { is CurrentPurchase.Success -> { logcat { "Switch flow: Successfully switched plans" } + lifecycleOwner.lifecycleScope.launch(dispatcherProvider.io()) { + subscriptionSwitchWideEvent.onUIRefreshed() + } onSwitchSuccess.invoke() } 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..104e079b0d6d --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/wideevents/SubscriptionSwitchWideEvent.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 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.repository.isActive +import com.squareup.anvil.annotations.ContributesBinding +import dagger.Lazy +import dagger.SingleInstanceIn +import kotlinx.coroutines.withContext +import java.time.Duration +import javax.inject.Inject + +interface SubscriptionSwitchWideEvent { + suspend fun onSwitchFlowStarted( + fromPlan: String, + toPlan: String, + switchType: String, + ) + + suspend fun onCurrentSubscriptionValidated() + + suspend fun onValidationFailure(error: String) + + suspend fun onTargetPlanRetrieved() + + suspend fun onTargetPlanRetrievalFailure() + + suspend fun onUserConfirmationShown() + + suspend fun onUserCancelled() + + suspend fun onBillingFlowInitSuccess() + + suspend fun onBillingFlowInitFailure(error: String) + + suspend fun onPlayBillingSwitchSuccess() + + suspend fun onPlayBillingSwitchFailure(error: String) + + suspend fun onSwitchConfirmed() + + suspend fun onSwitchConfirmationFailure(error: String) + + suspend fun onSubscriptionUpdated( + oldStatus: SubscriptionStatus?, + newStatus: SubscriptionStatus?, + ) + + suspend 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, +) : SubscriptionSwitchWideEvent { + + private var cachedFlowId: Long? = null + + override suspend fun onSwitchFlowStarted( + fromPlan: String, + toPlan: String, + switchType: String, + ) { + if (!isFeatureEnabled()) return + + getCurrentWideEventId()?.let { wideEventId -> + wideEventClient.flowFinish( + wideEventId = wideEventId, + status = FlowStatus.Unknown, + ) + cachedFlowId = null + } + + cachedFlowId = wideEventClient + .flowStart( + name = SUBSCRIPTION_SWITCH_FEATURE_NAME, + 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 + val wideEventId = getCurrentWideEventId() ?: return + + wideEventClient.flowStep( + wideEventId = wideEventId, + stepName = STEP_VALIDATE_CURRENT_SUBSCRIPTION, + success = true, + ) + } + + override suspend fun onValidationFailure(error: String) { + if (!isFeatureEnabled()) return + val wideEventId = getCurrentWideEventId() ?: return + + 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 + val wideEventId = getCurrentWideEventId() ?: return + + wideEventClient.flowStep( + wideEventId = wideEventId, + stepName = STEP_RETRIEVE_TARGET_PLAN, + success = true, + ) + } + + override suspend fun onTargetPlanRetrievalFailure() { + if (!isFeatureEnabled()) return + val wideEventId = getCurrentWideEventId() ?: return + + wideEventClient.flowStep( + wideEventId = wideEventId, + stepName = STEP_RETRIEVE_TARGET_PLAN, + success = false, + ) + + wideEventClient.flowFinish( + wideEventId = wideEventId, + status = FlowStatus.Failure(reason = "Failed to retrieve target plan"), + ) + cachedFlowId = null + } + + override suspend fun onUserConfirmationShown() { + if (!isFeatureEnabled()) return + val wideEventId = getCurrentWideEventId() ?: return + + wideEventClient.flowStep( + wideEventId = wideEventId, + stepName = STEP_USER_CONFIRMATION_SHOWN, + ) + } + + override suspend fun onUserCancelled() { + if (!isFeatureEnabled()) return + val wideEventId = getCurrentWideEventId() ?: return + + wideEventClient.flowFinish(wideEventId = wideEventId, status = FlowStatus.Cancelled) + cachedFlowId = null + } + + override suspend fun onBillingFlowInitSuccess() { + if (!isFeatureEnabled()) return + val wideEventId = getCurrentWideEventId() ?: return + + wideEventClient.flowStep( + wideEventId = wideEventId, + stepName = STEP_BILLING_FLOW_INIT, + success = true, + ) + } + + override suspend fun onBillingFlowInitFailure(error: String) { + if (!isFeatureEnabled()) return + val wideEventId = getCurrentWideEventId() ?: return + + 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 onPlayBillingSwitchSuccess() { + if (!isFeatureEnabled()) return + val wideEventId = getCurrentWideEventId() ?: return + + wideEventClient.intervalStart( + wideEventId = wideEventId, + key = KEY_ACTIVATION_LATENCY_MS_BUCKETED, + timeout = Duration.ofHours(4), + ) + + wideEventClient.flowStep( + wideEventId = wideEventId, + stepName = STEP_PLAY_BILLING_SWITCH, + success = true, + ) + } + + override suspend fun onPlayBillingSwitchFailure(error: String) { + if (!isFeatureEnabled()) return + val wideEventId = getCurrentWideEventId() ?: return + + wideEventClient.flowStep( + wideEventId = wideEventId, + stepName = STEP_PLAY_BILLING_SWITCH, + success = false, + ) + + wideEventClient.flowFinish( + wideEventId = wideEventId, + status = FlowStatus.Failure(reason = error), + ) + cachedFlowId = null + } + + override suspend fun onSwitchConfirmed() { + if (!isFeatureEnabled()) return + val wideEventId = getCurrentWideEventId() ?: return + + wideEventClient.flowStep( + wideEventId = wideEventId, + stepName = STEP_CONFIRM_SWITCH, + success = true, + ) + + wideEventClient.intervalEnd( + wideEventId = wideEventId, + key = KEY_ACTIVATION_LATENCY_MS_BUCKETED, + ) + + wideEventClient.flowFinish( + wideEventId = wideEventId, + status = FlowStatus.Success, + ) + + cachedFlowId = null + } + + override suspend fun onSwitchConfirmationFailure(error: String) { + if (!isFeatureEnabled()) return + val wideEventId = getCurrentWideEventId() ?: return + + wideEventClient.flowStep( + wideEventId = wideEventId, + stepName = STEP_CONFIRM_SWITCH, + success = false, + ) + + wideEventClient.flowFinish( + wideEventId = wideEventId, + status = FlowStatus.Failure(reason = error), + ) + 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_MS_BUCKETED) + wideEventClient.flowFinish(wideEventId = wideEventId, status = FlowStatus.Success) + cachedFlowId = null + } + } + + override suspend fun onUIRefreshed() { + if (!isFeatureEnabled()) return + val wideEventId = getCurrentWideEventId() ?: return + + wideEventClient.flowStep( + wideEventId = wideEventId, + stepName = STEP_UI_REFRESH, + ) + } + + override suspend fun onSwitchFailed(error: String) { + if (!isFeatureEnabled()) return + val wideEventId = getCurrentWideEventId() ?: return + + 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" + + // Wide event metadata keys + const val KEY_ACTIVATION_LATENCY_MS_BUCKETED = "activation_latency_ms_bucketed" + const val KEY_FROM_PLAN = "from_plan" + const val KEY_TO_PLAN = "to_plan" + const val KEY_SWITCH_TYPE = "switch_type" + + // Steps + const val STEP_VALIDATE_CURRENT_SUBSCRIPTION = "validate_current_subscription" + const val STEP_RETRIEVE_TARGET_PLAN = "retrieve_target_plan" + const val STEP_USER_CONFIRMATION_SHOWN = "user_confirmation_shown" + const val STEP_BILLING_FLOW_INIT = "billing_flow_init" + const val STEP_PLAY_BILLING_SWITCH = "billing_flow_switch" + const val STEP_CONFIRM_SWITCH = "confirm_switch" + const val STEP_UI_REFRESH = "ui_refresh" + } +} + +private fun Exception.toErrorString(): String = + listOf(javaClass.simpleName, message).joinToString(": ") + 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..0644e46fa8e9 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) @@ -156,6 +158,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { backgroundTokenRefresh, subscriptionPurchaseWideEvent, tokenRefreshWideEvent, + subscriptionSwitchWideEvent, ) } @@ -599,6 +602,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { backgroundTokenRefresh, subscriptionPurchaseWideEvent, tokenRefreshWideEvent, + subscriptionSwitchWideEvent, ) manager.subscriptionStatus.test { @@ -629,6 +633,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { backgroundTokenRefresh, subscriptionPurchaseWideEvent, tokenRefreshWideEvent, + subscriptionSwitchWideEvent, ) manager.subscriptionStatus.test { @@ -664,6 +669,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { backgroundTokenRefresh, subscriptionPurchaseWideEvent, tokenRefreshWideEvent, + subscriptionSwitchWideEvent, ) manager.currentPurchaseState.test { @@ -713,6 +719,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { backgroundTokenRefresh, subscriptionPurchaseWideEvent, tokenRefreshWideEvent, + subscriptionSwitchWideEvent, ) manager.currentPurchaseState.test { @@ -752,6 +759,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { backgroundTokenRefresh, subscriptionPurchaseWideEvent, tokenRefreshWideEvent, + subscriptionSwitchWideEvent, ) manager.currentPurchaseState.test { @@ -1103,6 +1111,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { backgroundTokenRefresh, subscriptionPurchaseWideEvent, tokenRefreshWideEvent, + subscriptionSwitchWideEvent, ) manager.signOut() verify(mockRepo).setSubscription(null) @@ -1150,6 +1159,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { backgroundTokenRefresh, subscriptionPurchaseWideEvent, tokenRefreshWideEvent, + subscriptionSwitchWideEvent, ) manager.subscriptionStatus.test { @@ -1333,6 +1343,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { backgroundTokenRefresh, subscriptionPurchaseWideEvent, tokenRefreshWideEvent, + subscriptionSwitchWideEvent, ) assertFalse(subscriptionsManager.canSupportEncryption()) From 1897a8903d4f97a79e3be6d24f57fdc69efe0395 Mon Sep 17 00:00:00 2001 From: nalcalag Date: Fri, 21 Nov 2025 10:54:19 -0800 Subject: [PATCH 2/6] Added origin to switch flow start --- .../com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt | 4 ++++ .../impl/switch_plan/SwitchPlanBottomSheetDialog.kt | 1 + .../impl/wideevents/SubscriptionSwitchWideEvent.kt | 3 +++ .../subscriptions/internal/settings/SwitchSubscriptionView.kt | 1 + 4 files changed, 9 insertions(+) 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 ace6baa86c30..b016903336a1 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 @@ -253,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( @@ -260,6 +261,7 @@ interface SubscriptionsManager { planId: String, offerId: String? = null, replacementMode: SubscriptionReplacementMode, + origin: String? = null, ) /** @@ -500,6 +502,7 @@ class RealSubscriptionsManager @Inject constructor( planId: String, offerId: String?, replacementMode: SubscriptionReplacementMode, + origin: String?, ) = withContext(dispatcherProvider.io()) { try { if (!isSignedIn()) { @@ -520,6 +523,7 @@ class RealSubscriptionsManager @Inject constructor( val isUpgrade = currentSubscription.productId in listOf(MONTHLY_PLAN_US, MONTHLY_PLAN_ROW) val switchType = if (isUpgrade) "upgrade" else "downgrade" subscriptionSwitchWideEvent.onSwitchFlowStarted( + context = origin, fromPlan = currentSubscription.productId, toPlan = planId, switchType = switchType, 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 44ffdea2637c..4a2049a75e9d 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 @@ -228,6 +228,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 index 104e079b0d6d..7c254b9f8bdc 100644 --- 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 @@ -33,6 +33,7 @@ import javax.inject.Inject interface SubscriptionSwitchWideEvent { suspend fun onSwitchFlowStarted( + context: String?, fromPlan: String, toPlan: String, switchType: String, @@ -83,6 +84,7 @@ class SubscriptionSwitchWideEventImpl @Inject constructor( private var cachedFlowId: Long? = null override suspend fun onSwitchFlowStarted( + context: String?, fromPlan: String, toPlan: String, switchType: String, @@ -100,6 +102,7 @@ class SubscriptionSwitchWideEventImpl @Inject constructor( cachedFlowId = wideEventClient .flowStart( name = SUBSCRIPTION_SWITCH_FEATURE_NAME, + flowEntryPoint = context, metadata = mapOf( KEY_FROM_PLAN to fromPlan, KEY_TO_PLAN to toPlan, 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()) { From ad553b1d70e314332ffd84dd719a26065938ed45 Mon Sep 17 00:00:00 2001 From: nalcalag Date: Mon, 24 Nov 2025 15:45:33 -0800 Subject: [PATCH 3/6] Cleanup and tests --- .../SubscriptionSwitchWideEventTest.kt | 361 ++++++++++++++++++ .../impl/SubscriptionsManager.kt | 48 +-- .../impl/billing/PlayBillingManager.kt | 13 +- .../SwitchPlanBottomSheetDialog.kt | 21 - .../wideevents/SubscriptionSwitchWideEvent.kt | 293 ++++++-------- .../impl/RealSubscriptionsManagerTest.kt | 8 + .../billing/RealPlayBillingManagerTest.kt | 1 + 7 files changed, 520 insertions(+), 225 deletions(-) create mode 100644 app/src/test/java/com/duckduckgo/subscriptions/impl/wideevents/SubscriptionSwitchWideEventTest.kt 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..fe44a7bf0cb2 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/subscriptions/impl/wideevents/SubscriptionSwitchWideEventTest.kt @@ -0,0 +1,361 @@ +/* + * 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, + ) + } + + @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", + switchType = "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(), + ) + } + + @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", + switchType = "upgrade", + ) + 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/SubscriptionsManager.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt index b016903336a1..9e5b587089fd 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 @@ -397,7 +397,6 @@ class RealSubscriptionsManager @Inject constructor( } } - override suspend fun canSupportEncryption(): Boolean = authRepository.canSupportEncryption() override suspend fun isFreeTrialEligible(): Boolean { @@ -505,16 +504,8 @@ class RealSubscriptionsManager @Inject constructor( origin: String?, ) = withContext(dispatcherProvider.io()) { try { - if (!isSignedIn()) { - logcat { "Subs: Cannot switch plan - user not signed in" } - subscriptionSwitchWideEvent.onValidationFailure("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()) { - subscriptionSwitchWideEvent.onValidationFailure("No active subscription found") _currentPurchaseState.emit(CurrentPurchase.Failure("No active subscription found for switch")) return@withContext } @@ -529,23 +520,33 @@ class RealSubscriptionsManager @Inject constructor( switchType = switchType, ) + 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" } - subscriptionSwitchWideEvent.onSwitchFailed("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" } - subscriptionSwitchWideEvent.onSwitchFailed("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 } @@ -553,9 +554,10 @@ 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" } + val errorMessage = "Target plan not found: $planId" + logcat { "Subs: Cannot switch plan - $errorMessage" } subscriptionSwitchWideEvent.onTargetPlanRetrievalFailure() - _currentPurchaseState.emit(CurrentPurchase.Failure("Target plan not found: $planId for switch")) + _currentPurchaseState.emit(CurrentPurchase.Failure(errorMessage)) return@withContext } @@ -575,12 +577,11 @@ class RealSubscriptionsManager @Inject constructor( replacementMode = replacementMode, ) } - - subscriptionSwitchWideEvent.onBillingFlowInitSuccess() } catch (e: Exception) { - logcat(ERROR) { "Subs: Failed to switch subscription plan: ${e.asLog()}" } - subscriptionSwitchWideEvent.onBillingFlowInitFailure(e.message ?: "Unknown error") - _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) } } @@ -723,10 +724,9 @@ class RealSubscriptionsManager @Inject constructor( _currentPurchaseState.emit(CurrentPurchase.Success) authRepository.registerLocalPurchasedAt() - subscriptionSwitchWideEvent.onSwitchConfirmed() + subscriptionSwitchWideEvent.onSwitchConfirmationSuccess() subscriptionPurchaseWideEvent.onPurchaseConfirmationSuccess() } else { - subscriptionSwitchWideEvent.onSwitchConfirmationFailure("Subscription not active after switch") handlePurchaseFailed() } 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..d0cd5e0d9639 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,6 +300,7 @@ class RealPlayBillingManager @Inject constructor( when (launchBillingFlowResult) { LaunchBillingFlowResult.Success -> { + subscriptionSwitchWideEvent.onBillingFlowInitSuccess() _purchaseState.emit(InProgress) billingFlowInProgress = true } 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 4a2049a75e9d..69607c01556f 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 @@ -92,11 +92,6 @@ class SwitchPlanBottomSheetDialog @AssistedInject constructor( override fun onAttachedToWindow() { super.onAttachedToWindow() - // Track that user confirmation dialog was shown - lifecycleOwner.lifecycleScope.launch(dispatcherProvider.io()) { - subscriptionSwitchWideEvent.onUserConfirmationShown() - } - configureViews() observePurchaseState() } @@ -134,10 +129,6 @@ class SwitchPlanBottomSheetDialog @AssistedInject constructor( dismiss() } binding.switchBottomSheetDialogSecondaryButton.setOnClickListener { - // User cancelled the switch by keeping monthly plan - lifecycleOwner.lifecycleScope.launch(dispatcherProvider.io()) { - subscriptionSwitchWideEvent.onUserCancelled() - } dismiss() } } @@ -160,10 +151,6 @@ class SwitchPlanBottomSheetDialog @AssistedInject constructor( ) binding.switchBottomSheetDialogPrimaryButton.setOnClickListener { - // User cancelled the switch by keeping yearly plan - lifecycleOwner.lifecycleScope.launch(dispatcherProvider.io()) { - subscriptionSwitchWideEvent.onUserCancelled() - } dismiss() } binding.switchBottomSheetDialogSecondaryButton.setOnClickListener { @@ -188,14 +175,6 @@ class SwitchPlanBottomSheetDialog @AssistedInject constructor( 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 -> {} } } 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 index 7c254b9f8bdc..196c1e8dcb78 100644 --- 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 @@ -47,21 +47,15 @@ interface SubscriptionSwitchWideEvent { suspend fun onTargetPlanRetrievalFailure() - suspend fun onUserConfirmationShown() - - suspend fun onUserCancelled() - suspend fun onBillingFlowInitSuccess() suspend fun onBillingFlowInitFailure(error: String) - suspend fun onPlayBillingSwitchSuccess() - - suspend fun onPlayBillingSwitchFailure(error: String) + suspend fun onUserCancelled() - suspend fun onSwitchConfirmed() + suspend fun onPlayBillingSwitchSuccess() - suspend fun onSwitchConfirmationFailure(error: String) + suspend fun onSwitchConfirmationSuccess() suspend fun onSubscriptionUpdated( oldStatus: SubscriptionStatus?, @@ -91,14 +85,6 @@ class SubscriptionSwitchWideEventImpl @Inject constructor( ) { if (!isFeatureEnabled()) return - getCurrentWideEventId()?.let { wideEventId -> - wideEventClient.flowFinish( - wideEventId = wideEventId, - status = FlowStatus.Unknown, - ) - cachedFlowId = null - } - cachedFlowId = wideEventClient .flowStart( name = SUBSCRIPTION_SWITCH_FEATURE_NAME, @@ -115,178 +101,139 @@ class SubscriptionSwitchWideEventImpl @Inject constructor( override suspend fun onCurrentSubscriptionValidated() { if (!isFeatureEnabled()) return - val wideEventId = getCurrentWideEventId() ?: return - wideEventClient.flowStep( - wideEventId = wideEventId, - stepName = STEP_VALIDATE_CURRENT_SUBSCRIPTION, - success = true, - ) + getCurrentWideEventId()?.let { wideEventId -> + wideEventClient.flowStep( + wideEventId = wideEventId, + stepName = STEP_VALIDATE_CURRENT_SUBSCRIPTION, + success = true, + ) + } } override suspend fun onValidationFailure(error: String) { if (!isFeatureEnabled()) return - val wideEventId = getCurrentWideEventId() ?: return - - wideEventClient.flowStep( - wideEventId = wideEventId, - stepName = STEP_VALIDATE_CURRENT_SUBSCRIPTION, - success = false, - ) - - wideEventClient.flowFinish( - wideEventId = wideEventId, - status = FlowStatus.Failure(reason = error), - ) - cachedFlowId = null + + 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 - val wideEventId = getCurrentWideEventId() ?: return - wideEventClient.flowStep( - wideEventId = wideEventId, - stepName = STEP_RETRIEVE_TARGET_PLAN, - success = true, - ) + getCurrentWideEventId()?.let { wideEventId -> + wideEventClient.flowStep( + wideEventId = wideEventId, + stepName = STEP_RETRIEVE_TARGET_PLAN, + success = true, + ) + } } override suspend fun onTargetPlanRetrievalFailure() { if (!isFeatureEnabled()) return - val wideEventId = getCurrentWideEventId() ?: return - - wideEventClient.flowStep( - wideEventId = wideEventId, - stepName = STEP_RETRIEVE_TARGET_PLAN, - success = false, - ) - - wideEventClient.flowFinish( - wideEventId = wideEventId, - status = FlowStatus.Failure(reason = "Failed to retrieve target plan"), - ) - cachedFlowId = null - } - - override suspend fun onUserConfirmationShown() { - if (!isFeatureEnabled()) return - val wideEventId = getCurrentWideEventId() ?: return - wideEventClient.flowStep( - wideEventId = wideEventId, - stepName = STEP_USER_CONFIRMATION_SHOWN, - ) - } - - override suspend fun onUserCancelled() { - if (!isFeatureEnabled()) return - val wideEventId = getCurrentWideEventId() ?: return - - wideEventClient.flowFinish(wideEventId = wideEventId, status = FlowStatus.Cancelled) - cachedFlowId = null + 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 - val wideEventId = getCurrentWideEventId() ?: return - wideEventClient.flowStep( - wideEventId = wideEventId, - stepName = STEP_BILLING_FLOW_INIT, - success = true, - ) + getCurrentWideEventId()?.let { wideEventId -> + wideEventClient.flowStep( + wideEventId = wideEventId, + stepName = STEP_BILLING_FLOW_INIT, + success = true, + ) + } } override suspend fun onBillingFlowInitFailure(error: String) { if (!isFeatureEnabled()) return - val wideEventId = getCurrentWideEventId() ?: return - - 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 onPlayBillingSwitchSuccess() { - if (!isFeatureEnabled()) return - val wideEventId = getCurrentWideEventId() ?: return - - wideEventClient.intervalStart( - wideEventId = wideEventId, - key = KEY_ACTIVATION_LATENCY_MS_BUCKETED, - timeout = Duration.ofHours(4), - ) - - wideEventClient.flowStep( - wideEventId = wideEventId, - stepName = STEP_PLAY_BILLING_SWITCH, - success = true, - ) + 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 onPlayBillingSwitchFailure(error: String) { + override suspend fun onUserCancelled() { if (!isFeatureEnabled()) return - val wideEventId = getCurrentWideEventId() ?: return - - wideEventClient.flowStep( - wideEventId = wideEventId, - stepName = STEP_PLAY_BILLING_SWITCH, - success = false, - ) - - wideEventClient.flowFinish( - wideEventId = wideEventId, - status = FlowStatus.Failure(reason = error), - ) - cachedFlowId = null + + getCurrentWideEventId()?.let { wideEventId -> + wideEventClient.flowFinish( + wideEventId = wideEventId, + status = FlowStatus.Cancelled, + ) + cachedFlowId = null + } } - override suspend fun onSwitchConfirmed() { + override suspend fun onPlayBillingSwitchSuccess() { if (!isFeatureEnabled()) return - val wideEventId = getCurrentWideEventId() ?: return - - wideEventClient.flowStep( - wideEventId = wideEventId, - stepName = STEP_CONFIRM_SWITCH, - success = true, - ) - - wideEventClient.intervalEnd( - wideEventId = wideEventId, - key = KEY_ACTIVATION_LATENCY_MS_BUCKETED, - ) - wideEventClient.flowFinish( - wideEventId = wideEventId, - status = FlowStatus.Success, - ) - - cachedFlowId = null + 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 onSwitchConfirmationFailure(error: String) { + override suspend fun onSwitchConfirmationSuccess() { if (!isFeatureEnabled()) return - val wideEventId = getCurrentWideEventId() ?: return - - wideEventClient.flowStep( - wideEventId = wideEventId, - stepName = STEP_CONFIRM_SWITCH, - success = false, - ) - - wideEventClient.flowFinish( - wideEventId = wideEventId, - status = FlowStatus.Failure(reason = error), - ) - cachedFlowId = null + + 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( @@ -297,7 +244,7 @@ class SubscriptionSwitchWideEventImpl @Inject constructor( if (oldStatus == SubscriptionStatus.WAITING && newStatus?.isActive() == true) { val wideEventId = getCurrentWideEventId() ?: return - wideEventClient.intervalEnd(wideEventId = wideEventId, key = KEY_ACTIVATION_LATENCY_MS_BUCKETED) + wideEventClient.intervalEnd(wideEventId = wideEventId, key = KEY_ACTIVATION_LATENCY) wideEventClient.flowFinish(wideEventId = wideEventId, status = FlowStatus.Success) cachedFlowId = null } @@ -305,24 +252,25 @@ class SubscriptionSwitchWideEventImpl @Inject constructor( override suspend fun onUIRefreshed() { if (!isFeatureEnabled()) return - val wideEventId = getCurrentWideEventId() ?: return - wideEventClient.flowStep( - wideEventId = wideEventId, - stepName = STEP_UI_REFRESH, - ) + getCurrentWideEventId()?.let { wideEventId -> + wideEventClient.flowStep( + wideEventId = wideEventId, + stepName = STEP_UI_REFRESH, + ) + } } override suspend fun onSwitchFailed(error: String) { if (!isFeatureEnabled()) return - val wideEventId = getCurrentWideEventId() ?: return - - wideEventClient.flowFinish( - wideEventId = wideEventId, - status = FlowStatus.Failure(reason = error), - ) - cachedFlowId = null + getCurrentWideEventId()?.let { wideEventId -> + wideEventClient.flowFinish( + wideEventId = wideEventId, + status = FlowStatus.Failure(reason = error), + ) + cachedFlowId = null + } } private suspend fun isFeatureEnabled(): Boolean = withContext(dispatchers.io()) { @@ -342,24 +290,17 @@ class SubscriptionSwitchWideEventImpl @Inject constructor( private companion object { const val SUBSCRIPTION_SWITCH_FEATURE_NAME = "subscription-switch" - - // Wide event metadata keys - const val KEY_ACTIVATION_LATENCY_MS_BUCKETED = "activation_latency_ms_bucketed" 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" // Steps const val STEP_VALIDATE_CURRENT_SUBSCRIPTION = "validate_current_subscription" const val STEP_RETRIEVE_TARGET_PLAN = "retrieve_target_plan" - const val STEP_USER_CONFIRMATION_SHOWN = "user_confirmation_shown" const val STEP_BILLING_FLOW_INIT = "billing_flow_init" - const val STEP_PLAY_BILLING_SWITCH = "billing_flow_switch" + const val STEP_BILLING_FLOW_SWITCH = "billing_flow_switch" const val STEP_CONFIRM_SWITCH = "confirm_switch" const val STEP_UI_REFRESH = "ui_refresh" } } - -private fun Exception.toErrorString(): String = - listOf(javaClass.simpleName, message).joinToString(": ") - 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 0644e46fa8e9..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 @@ -140,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, @@ -584,6 +585,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { @Test fun whenSubscribedToSubscriptionStatusThenEmit() = runTest { + whenever(playBillingManager.purchaseState).thenReturn(flowOf()) val manager = RealSubscriptionsManager( authService, subscriptionsService, @@ -615,6 +617,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { fun whenSubscribedToSubscriptionStatusAndSubscriptionExistsThenEmit() = runTest { givenUserIsSignedIn() givenSubscriptionExists() + whenever(playBillingManager.purchaseState).thenReturn(flowOf()) val manager = RealSubscriptionsManager( authService, subscriptionsService, @@ -1093,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, @@ -1140,6 +1144,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { fun whenSignOutThenEmitUnknown() = runTest { givenUserIsSignedIn() givenSubscriptionExists() + whenever(playBillingManager.purchaseState).thenReturn(flowOf()) val manager = RealSubscriptionsManager( authService, @@ -1325,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, @@ -1843,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 From 2edc300948e58ec65a4dd27ca677b01aaa9b0f00 Mon Sep 17 00:00:00 2001 From: nalcalag Date: Mon, 24 Nov 2025 18:16:24 -0800 Subject: [PATCH 4/6] Added @SuppressLint("DenyListedApi") --- .../impl/wideevents/SubscriptionSwitchWideEventTest.kt | 1 + 1 file changed, 1 insertion(+) 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 index fe44a7bf0cb2..790a74146483 100644 --- a/app/src/test/java/com/duckduckgo/subscriptions/impl/wideevents/SubscriptionSwitchWideEventTest.kt +++ b/app/src/test/java/com/duckduckgo/subscriptions/impl/wideevents/SubscriptionSwitchWideEventTest.kt @@ -312,6 +312,7 @@ class SubscriptionSwitchWideEventTest { ) } + @SuppressLint("DenyListedApi") @Test fun `when feature disabled then no events are sent`() = runTest { privacyProFeature.sendSubscriptionSwitchWideEvent().setRawStoredState(Toggle.State(false)) From 7b57261b184b5df4d4f60e6b6c36a97d8e3a4910 Mon Sep 17 00:00:00 2001 From: nalcalag Date: Tue, 25 Nov 2025 20:48:57 -0800 Subject: [PATCH 5/6] Change events so it matches purchase flow --- .../com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt | 1 - .../subscriptions/impl/billing/PlayBillingManager.kt | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) 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 9e5b587089fd..d9ab22d16293 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 @@ -380,7 +380,6 @@ class RealSubscriptionsManager @Inject constructor( } is PurchaseState.Canceled -> { _currentPurchaseState.emit(CurrentPurchase.Canceled) - subscriptionSwitchWideEvent.onUserCancelled() if (removeExpiredSubscriptionOnCancelledPurchase) { if (subscriptionStatus().isExpired()) { signOut() 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 d0cd5e0d9639..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 @@ -306,6 +306,7 @@ class RealPlayBillingManager @Inject constructor( } is LaunchBillingFlowResult.Failure -> { + subscriptionSwitchWideEvent.onBillingFlowInitFailure(launchBillingFlowResult.error.name) _purchaseState.emit(Canceled) } } @@ -326,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) } From 4881b9cb75394aa4bb3c7e8a52ef598d3dccd6b1 Mon Sep 17 00:00:00 2001 From: nalcalag Date: Wed, 26 Nov 2025 12:29:27 -0800 Subject: [PATCH 6/6] Addressed PR comments --- .../SubscriptionSwitchWideEventTest.kt | 16 ++++----- .../impl/SubscriptionsManager.kt | 3 -- .../SwitchPlanBottomSheetDialog.kt | 4 +-- .../wideevents/SubscriptionSwitchWideEvent.kt | 33 +++++++++++++------ 4 files changed, 32 insertions(+), 24 deletions(-) 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 index 790a74146483..753d073f9c91 100644 --- a/app/src/test/java/com/duckduckgo/subscriptions/impl/wideevents/SubscriptionSwitchWideEventTest.kt +++ b/app/src/test/java/com/duckduckgo/subscriptions/impl/wideevents/SubscriptionSwitchWideEventTest.kt @@ -57,6 +57,7 @@ class SubscriptionSwitchWideEventTest { wideEventClient = wideEventClient, privacyProFeature = { privacyProFeature }, dispatchers = coroutineRule.testDispatcherProvider, + appCoroutineScope = coroutineRule.testScope, ) } @@ -66,17 +67,17 @@ class SubscriptionSwitchWideEventTest { subscriptionSwitchWideEvent.onSwitchFlowStarted( context = "subscription_settings", - fromPlan = "ddg.privacy.pro.monthly.renews.us", - toPlan = "ddg.privacy.pro.yearly.renews.us", - switchType = "upgrade", + 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", + "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), @@ -323,9 +324,8 @@ class SubscriptionSwitchWideEventTest { subscriptionSwitchWideEvent.onSwitchFlowStarted( context = "subscription_settings", - fromPlan = "ddg.privacy.pro.monthly.renews.us", - toPlan = "ddg.privacy.pro.yearly.renews.us", - switchType = "upgrade", + fromPlan = "ddg-privacy-pro-monthly-renews-us", + toPlan = "ddg-privacy-pro-yearly-renews-us", ) subscriptionSwitchWideEvent.onCurrentSubscriptionValidated() subscriptionSwitchWideEvent.onTargetPlanRetrieved() 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 d9ab22d16293..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 @@ -510,13 +510,10 @@ class RealSubscriptionsManager @Inject constructor( } // Start wide event tracking - val isUpgrade = currentSubscription.productId in listOf(MONTHLY_PLAN_US, MONTHLY_PLAN_ROW) - val switchType = if (isUpgrade) "upgrade" else "downgrade" subscriptionSwitchWideEvent.onSwitchFlowStarted( context = origin, fromPlan = currentSubscription.productId, toPlan = planId, - switchType = switchType, ) if (!isSignedIn()) { 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 69607c01556f..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 @@ -169,9 +169,7 @@ class SwitchPlanBottomSheetDialog @AssistedInject constructor( when (it) { is CurrentPurchase.Success -> { logcat { "Switch flow: Successfully switched plans" } - lifecycleOwner.lifecycleScope.launch(dispatcherProvider.io()) { - subscriptionSwitchWideEvent.onUIRefreshed() - } + subscriptionSwitchWideEvent.onUIRefreshed() onSwitchSuccess.invoke() } 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 index 196c1e8dcb78..4350b956ec30 100644 --- 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 @@ -16,6 +16,7 @@ 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 @@ -23,10 +24,14 @@ 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 @@ -36,7 +41,6 @@ interface SubscriptionSwitchWideEvent { context: String?, fromPlan: String, toPlan: String, - switchType: String, ) suspend fun onCurrentSubscriptionValidated() @@ -62,7 +66,7 @@ interface SubscriptionSwitchWideEvent { newStatus: SubscriptionStatus?, ) - suspend fun onUIRefreshed() + fun onUIRefreshed() suspend fun onSwitchFailed(error: String) } @@ -73,6 +77,7 @@ 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 @@ -81,10 +86,12 @@ class SubscriptionSwitchWideEventImpl @Inject constructor( context: String?, fromPlan: String, toPlan: String, - switchType: 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, @@ -250,14 +257,16 @@ class SubscriptionSwitchWideEventImpl @Inject constructor( } } - override suspend fun onUIRefreshed() { - if (!isFeatureEnabled()) return + override fun onUIRefreshed() { + appCoroutineScope.launch(dispatchers.io()) { + if (!isFeatureEnabled()) return@launch - getCurrentWideEventId()?.let { wideEventId -> - wideEventClient.flowStep( - wideEventId = wideEventId, - stepName = STEP_UI_REFRESH, - ) + getCurrentWideEventId()?.let { wideEventId -> + wideEventClient.flowStep( + wideEventId = wideEventId, + stepName = STEP_UI_REFRESH, + ) + } } } @@ -295,6 +304,10 @@ class SubscriptionSwitchWideEventImpl @Inject constructor( 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"