From 44760625d84b69e1870f39995a02e6b5bdd0e23e Mon Sep 17 00:00:00 2001 From: Lukasz Macionczyk Date: Thu, 22 Feb 2024 11:16:59 +0100 Subject: [PATCH] Pixels to measure subscription funnel (#4202) Task/Issue URL: https://app.asana.com/0/1205648422731273/1206637905023887/f ### Description See task. ### No UI changes --- subscriptions/subscriptions-impl/build.gradle | 1 + .../impl/SubscriptionsManager.kt | 35 +++- .../impl/billing/BillingClientWrapper.kt | 3 + .../SubscriptionMessagingInterface.kt | 7 +- .../impl/pixels/SubscriptionPixel.kt | 161 ++++++++++++++++ .../impl/pixels/SubscriptionPixelSender.kt | 182 ++++++++++++++++++ .../SubscriptionRefreshRetentionAtbPlugin.kt | 44 +++++ .../settings/views/ItrSettingViewModel.kt | 11 +- .../settings/views/PirSettingViewModel.kt | 11 +- .../impl/settings/views/ProSettingView.kt | 62 ++++++ .../settings/views/ProSettingViewModel.kt | 8 +- .../impl/ui/AddDeviceViewModel.kt | 4 + .../impl/ui/RestoreSubscriptionViewModel.kt | 16 +- .../impl/ui/SubscriptionSettingsActivity.kt | 12 ++ .../impl/ui/SubscriptionSettingsViewModel.kt | 4 + .../impl/ui/SubscriptionWebViewViewModel.kt | 13 ++ .../impl/ui/SubscriptionsWebViewActivity.kt | 8 + .../impl/RealSubscriptionsManagerTest.kt | 104 ++++++++++ .../SubscriptionMessagingInterfaceTest.kt | 7 + .../impl/pixels/SubscriptionPixelTest.kt | 47 +++++ ...bscriptionRefreshRetentionAtbPluginTest.kt | 44 +++++ .../settings/views/ItrSettingViewModelTest.kt | 11 +- .../settings/views/PirSettingViewModelTest.kt | 11 +- .../impl/ui/AddDeviceViewModelTest.kt | 11 +- .../ui/RestoreSubscriptionViewModelTest.kt | 65 ++++++- .../ui/SubscriptionSettingsViewModelTest.kt | 11 +- .../ui/SubscriptionWebViewViewModelTest.kt | 75 ++++++++ 27 files changed, 946 insertions(+), 22 deletions(-) create mode 100644 subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionPixel.kt create mode 100644 subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionPixelSender.kt create mode 100644 subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionRefreshRetentionAtbPlugin.kt create mode 100644 subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionPixelTest.kt create mode 100644 subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionRefreshRetentionAtbPluginTest.kt diff --git a/subscriptions/subscriptions-impl/build.gradle b/subscriptions/subscriptions-impl/build.gradle index 900469244e2f..8e352d1aa5af 100644 --- a/subscriptions/subscriptions-impl/build.gradle +++ b/subscriptions/subscriptions-impl/build.gradle @@ -49,6 +49,7 @@ dependencies { implementation project(path: ':macos-api') implementation project(path: ':windows-api') implementation project(path: ':downloads-api') + implementation project(path: ':statistics') implementation AndroidX.appCompat implementation KotlinX.coroutines.core 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 467086fb0925..14e574bc45bf 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 @@ -32,6 +32,7 @@ import com.duckduckgo.subscriptions.impl.SubscriptionStatus.Unknown import com.duckduckgo.subscriptions.impl.SubscriptionsData.* import com.duckduckgo.subscriptions.impl.billing.BillingClientWrapper import com.duckduckgo.subscriptions.impl.billing.PurchaseState +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.repository.AuthRepository import com.duckduckgo.subscriptions.impl.services.AuthService import com.duckduckgo.subscriptions.impl.services.CreateAccountResponse @@ -40,6 +41,8 @@ import com.duckduckgo.subscriptions.impl.services.ResponseError import com.duckduckgo.subscriptions.impl.services.StoreLoginBody import com.duckduckgo.subscriptions.impl.services.SubscriptionsService import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.moshi.JsonDataException +import com.squareup.moshi.JsonEncodingException import com.squareup.moshi.Moshi import dagger.SingleInstanceIn import javax.inject.Inject @@ -142,6 +145,7 @@ class RealSubscriptionsManager @Inject constructor( private val context: Context, @AppCoroutineScope private val coroutineScope: CoroutineScope, private val dispatcherProvider: DispatcherProvider, + private val pixelSender: SubscriptionPixelSender, ) : SubscriptionsManager { private val adapter = Moshi.Builder().build().adapter(ResponseError::class.java) @@ -246,8 +250,11 @@ class RealSubscriptionsManager @Inject constructor( retries++ } if (hasSubscription) { + pixelSender.reportPurchaseSuccess() + pixelSender.reportSubscriptionActivated() _currentPurchaseState.emit(CurrentPurchase.Success) } else { + pixelSender.reportPurchaseFailureBackend() _currentPurchaseState.emit(CurrentPurchase.Failure("An error happened, try again")) } _hasSubscription.emit(hasSubscription) @@ -293,7 +300,11 @@ class RealSubscriptionsManager @Inject constructor( val storeLoginBody = StoreLoginBody(signature = signature, signedData = body, packageName = context.packageName) val response = authService.storeLogin(storeLoginBody) logcat(LogPriority.DEBUG) { "Subs: store login succeeded" } - authenticate(response.authToken) + val subscriptionsData = authenticate(response.authToken) + if (subscriptionsData is Success && subscriptionsData.entitlements.isNotEmpty()) { + pixelSender.reportSubscriptionActivated() + } + subscriptionsData } else { Failure(SUBSCRIPTION_NOT_FOUND_ERROR) } @@ -342,11 +353,13 @@ class RealSubscriptionsManager @Inject constructor( billingClientWrapper.launchBillingFlow(activity, billingParams) } } else { + pixelSender.reportRestoreAfterPurchaseAttemptSuccess() _currentPurchaseState.emit(CurrentPurchase.Recovered) } } is Failure -> { logcat(LogPriority.ERROR) { "Subs: ${response.message}" } + pixelSender.reportPurchaseFailureOther() _currentPurchaseState.emit(CurrentPurchase.Failure(response.message)) } } @@ -356,6 +369,11 @@ class RealSubscriptionsManager @Inject constructor( return try { val subscriptionData = if (isUserAuthenticated()) { getSubscriptionDataFromToken(authRepository.tokens().accessToken!!) + .also { subscriptionsData -> + if (subscriptionsData is Success && subscriptionsData.entitlements.isNotEmpty()) { + pixelSender.reportSubscriptionActivated() + } + } } else { recoverSubscriptionFromStore() } @@ -436,7 +454,20 @@ class RealSubscriptionsManager @Inject constructor( } private suspend fun createAccount(): CreateAccountResponse { - return authService.createAccount("Bearer ${emailManager.getToken()}") + try { + val account = authService.createAccount("Bearer ${emailManager.getToken()}") + if (account.authToken.isEmpty()) { + pixelSender.reportPurchaseFailureAccountCreation() + } + return account + } catch (e: Exception) { + when (e) { + is JsonDataException, is JsonEncodingException, is HttpException -> { + pixelSender.reportPurchaseFailureAccountCreation() + } + } + throw e + } } private fun parseError(e: HttpException): ResponseError? { diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/BillingClientWrapper.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/BillingClientWrapper.kt index 89ff99ac3afc..6ab0d22c4b8c 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/BillingClientWrapper.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/BillingClientWrapper.kt @@ -45,6 +45,7 @@ import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.LIST_OF_PRODUCTS import com.duckduckgo.subscriptions.impl.billing.PurchaseState.Canceled import com.duckduckgo.subscriptions.impl.billing.PurchaseState.InProgress import com.duckduckgo.subscriptions.impl.billing.PurchaseState.Purchased +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesMultibinding import dagger.SingleInstanceIn @@ -80,6 +81,7 @@ class RealBillingClientWrapper @Inject constructor( private val context: Context, val dispatcherProvider: DispatcherProvider, @AppCoroutineScope val coroutineScope: CoroutineScope, + private val pixelSender: SubscriptionPixelSender, ) : BillingClientWrapper, MainProcessLifecycleObserver { private var billingFlowInProcess = false @@ -109,6 +111,7 @@ class RealBillingClientWrapper @Inject constructor( } // Handle an error caused by a user cancelling the purchase flow. } else { + pixelSender.reportPurchaseFailureStore() coroutineScope.launch(dispatcherProvider.io()) { _purchaseState.emit(Canceled) } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt index 84df0d960def..5db335fb7a13 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterface.kt @@ -35,6 +35,7 @@ import com.duckduckgo.js.messaging.api.SubscriptionEventData import com.duckduckgo.subscriptions.impl.AuthToken import com.duckduckgo.subscriptions.impl.JSONObjectAdapter import com.duckduckgo.subscriptions.impl.SubscriptionsManager +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.squareup.anvil.annotations.ContributesBinding import com.squareup.moshi.Moshi import javax.inject.Inject @@ -52,6 +53,7 @@ class SubscriptionMessagingInterface @Inject constructor( private val jsMessageHelper: JsMessageHelper, private val dispatcherProvider: DispatcherProvider, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val pixelSender: SubscriptionPixelSender, ) : JsMessaging { private val moshi = Moshi.Builder().add(JSONObjectAdapter()).build() @@ -61,7 +63,7 @@ class SubscriptionMessagingInterface @Inject constructor( private val handlers = listOf( SubscriptionsHandler(), GetSubscriptionMessage(subscriptionsManager, dispatcherProvider), - SetSubscriptionMessage(subscriptionsManager, appCoroutineScope, dispatcherProvider), + SetSubscriptionMessage(subscriptionsManager, appCoroutineScope, dispatcherProvider, pixelSender), ) @JavascriptInterface @@ -185,12 +187,15 @@ class SubscriptionMessagingInterface @Inject constructor( private val subscriptionsManager: SubscriptionsManager, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val dispatcherProvider: DispatcherProvider, + private val pixelSender: SubscriptionPixelSender, ) : JsMessageHandler { override fun process(jsMessage: JsMessage, secret: String, jsMessageCallback: JsMessageCallback?) { try { val token = jsMessage.params.getString("token") appCoroutineScope.launch(dispatcherProvider.io()) { subscriptionsManager.authenticate(token) + pixelSender.reportRestoreUsingEmailSuccess() + pixelSender.reportSubscriptionActivated() } } catch (e: Exception) { logcat { "Error parsing the token" } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionPixel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionPixel.kt new file mode 100644 index 000000000000..2151b78162ed --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionPixel.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2024 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.pixels + +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.DAILY +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.UNIQUE +import java.util.EnumSet + +enum class SubscriptionPixel( + private val baseName: String, + private val types: Set, +) { + SETTINGS_SUBSCRIPTION_SECTION_SHOWN( + baseName = "m_privacy-pro_app-settings_privacy-pro-section_impression", + type = COUNT, + ), + SUBSCRIPTION_ACTIVE( + baseName = "m_privacy-pro_app_subscription_active", + type = DAILY, + ), + OFFER_SCREEN_SHOWN( + baseName = "m_privacy-pro_offer_screen_impression", + type = COUNT, + ), + OFFER_SUBSCRIBE_CLICK( + baseName = "m_privacy-pro_terms-conditions_subscribe_click", + types = EnumSet.of(COUNT, DAILY), + ), + PURCHASE_FAILURE_OTHER( + baseName = "m_privacy-pro_app_subscription-purchase_failure_other", + types = EnumSet.of(COUNT, DAILY), + ), + PURCHASE_FAILURE_STORE( + baseName = "m_privacy-pro_app_subscription-purchase_failure_store", + types = EnumSet.of(COUNT, DAILY), + ), + PURCHASE_FAILURE_BACKEND( + baseName = "m_privacy-pro_app_subscription-purchase_failure_backend", + types = EnumSet.of(COUNT, DAILY), + ), + PURCHASE_FAILURE_ACCOUNT_CREATION( + baseName = "m_privacy-pro_app_subscription-purchase_failure_account-creation", + types = EnumSet.of(COUNT, DAILY), + ), + PURCHASE_SUCCESS( + baseName = "m_privacy-pro_app_subscription-purchase_success", + types = EnumSet.of(COUNT, DAILY), + ), + OFFER_RESTORE_PURCHASE_CLICK( + baseName = "m_privacy-pro_offer_restore-purchase_click", + type = COUNT, + ), + ACTIVATE_SUBSCRIPTION_ENTER_EMAIL_CLICK( + baseName = "m_privacy-pro_activate-subscription_enter-email_click", + types = EnumSet.of(COUNT, DAILY), + ), + ACTIVATE_SUBSCRIPTION_RESTORE_PURCHASE_CLICK( + baseName = "m_privacy-pro_activate-subscription_restore-purchase_click", + types = EnumSet.of(COUNT, DAILY), + ), + RESTORE_USING_EMAIL_SUCCESS( + baseName = "m_privacy-pro_app_subscription-restore-using-email_success", + types = EnumSet.of(COUNT, DAILY), + ), + RESTORE_USING_STORE_SUCCESS( + baseName = "m_privacy-pro_app_subscription-restore-using-store_success", + types = EnumSet.of(COUNT, DAILY), + ), + RESTORE_USING_STORE_FAILURE_SUBSCRIPTION_NOT_FOUND( + baseName = "m_privacy-pro_app_subscription-restore-using-store_failure_not-found", + types = EnumSet.of(COUNT, DAILY), + ), + RESTORE_USING_STORE_FAILURE_OTHER( + baseName = "m_privacy-pro_app_subscription-restore-using-store_failure_other", + types = EnumSet.of(COUNT, DAILY), + ), + RESTORE_AFTER_PURCHASE_ATTEMPT_SUCCESS( + baseName = "m_privacy-pro_app_subscription-restore-after-purchase-attempt_success", + type = COUNT, + ), + SUBSCRIPTION_ACTIVATED( + baseName = "m_privacy-pro_app_subscription_activated", + type = UNIQUE, + ), + ONBOARDING_ADD_DEVICE_CLICK( + baseName = "m_privacy-pro_welcome_add-device_click", + type = UNIQUE, + ), + SETTINGS_ADD_DEVICE_CLICK( + baseName = "m_privacy-pro_settings_add-device_click", + type = COUNT, + ), + ADD_DEVICE_ENTER_EMAIL_CLICK( + baseName = "m_privacy-pro_add-device_enter-email_click", + type = COUNT, + ), + ONBOARDING_VPN_CLICK( + baseName = "m_privacy-pro_welcome_vpn_click", + type = UNIQUE, + ), + ONBOARDING_PIR_CLICK( + baseName = "m_privacy-pro_welcome_personal-information-removal_click", + type = UNIQUE, + ), + ONBOARDING_IDTR_CLICK( + baseName = "m_privacy-pro_welcome_identity-theft-restoration_click", + type = UNIQUE, + ), + SUBSCRIPTION_SETTINGS_SHOWN( + baseName = "m_privacy-pro_settings_screen_impression", + type = COUNT, + ), + APP_SETTINGS_PIR_CLICK( + baseName = "m_privacy-pro_app-settings_personal-information-removal_click", + type = COUNT, + ), + APP_SETTINGS_IDTR_CLICK( + baseName = "m_privacy-pro_app-settings_identity-theft-restoration_click", + type = COUNT, + ), + SUBSCRIPTION_SETTINGS_CHANGE_PLAN_OR_BILLING_CLICK( + baseName = "m_privacy-pro_settings_change-plan-or-billing_click", + type = COUNT, + ), + SUBSCRIPTION_SETTINGS_REMOVE_FROM_DEVICE_CLICK( + baseName = "m_privacy-pro_settings_remove-from-device_click", + type = COUNT, + ), + ; + + constructor( + baseName: String, + type: PixelType, + ) : this(baseName, EnumSet.of(type)) + + fun getPixelNames(): Map = + types.associateWith { type -> "${baseName}_${type.pixelNameSuffix}" } +} + +private val PixelType.pixelNameSuffix: String + get() = when (this) { + COUNT -> "c" + DAILY -> "d" + UNIQUE -> "u" + } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionPixelSender.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionPixelSender.kt new file mode 100644 index 000000000000..ea11dc2e3eed --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionPixelSender.kt @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2024 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.pixels + +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.ACTIVATE_SUBSCRIPTION_ENTER_EMAIL_CLICK +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.ACTIVATE_SUBSCRIPTION_RESTORE_PURCHASE_CLICK +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.ADD_DEVICE_ENTER_EMAIL_CLICK +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.APP_SETTINGS_IDTR_CLICK +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.APP_SETTINGS_PIR_CLICK +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.OFFER_RESTORE_PURCHASE_CLICK +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.OFFER_SCREEN_SHOWN +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.OFFER_SUBSCRIBE_CLICK +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.ONBOARDING_ADD_DEVICE_CLICK +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.ONBOARDING_IDTR_CLICK +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.ONBOARDING_PIR_CLICK +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.ONBOARDING_VPN_CLICK +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.PURCHASE_FAILURE_ACCOUNT_CREATION +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.PURCHASE_FAILURE_BACKEND +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.PURCHASE_FAILURE_OTHER +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.PURCHASE_FAILURE_STORE +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.PURCHASE_SUCCESS +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.RESTORE_AFTER_PURCHASE_ATTEMPT_SUCCESS +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.RESTORE_USING_EMAIL_SUCCESS +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.RESTORE_USING_STORE_FAILURE_OTHER +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.RESTORE_USING_STORE_FAILURE_SUBSCRIPTION_NOT_FOUND +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.RESTORE_USING_STORE_SUCCESS +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.SETTINGS_ADD_DEVICE_CLICK +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.SETTINGS_SUBSCRIPTION_SECTION_SHOWN +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.SUBSCRIPTION_ACTIVATED +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.SUBSCRIPTION_ACTIVE +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.SUBSCRIPTION_SETTINGS_CHANGE_PLAN_OR_BILLING_CLICK +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.SUBSCRIPTION_SETTINGS_REMOVE_FROM_DEVICE_CLICK +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixel.SUBSCRIPTION_SETTINGS_SHOWN +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject + +interface SubscriptionPixelSender { + fun reportSubscriptionSettingsSectionShown() + fun reportSubscriptionActive() + fun reportOfferScreenShown() + fun reportOfferSubscribeClick() + fun reportPurchaseFailureOther() + fun reportPurchaseFailureStore() + fun reportPurchaseFailureBackend() + fun reportPurchaseFailureAccountCreation() + fun reportPurchaseSuccess() + fun reportOfferRestorePurchaseClick() + fun reportActivateSubscriptionEnterEmailClick() + fun reportActivateSubscriptionRestorePurchaseClick() + fun reportRestoreUsingEmailSuccess() + fun reportRestoreUsingStoreSuccess() + fun reportRestoreUsingStoreFailureSubscriptionNotFound() + fun reportRestoreUsingStoreFailureOther() + fun reportRestoreAfterPurchaseAttemptSuccess() + fun reportSubscriptionActivated() + fun reportOnboardingAddDeviceClick() + fun reportSettingsAddDeviceClick() + fun reportAddDeviceEnterEmailClick() + fun reportOnboardingVpnClick() + fun reportOnboardingPirClick() + fun reportOnboardingIdtrClick() + fun reportSubscriptionSettingsShown() + fun reportAppSettingsPirClick() + fun reportAppSettingsIdtrClick() + fun reportSubscriptionSettingsChangePlanOrBillingClick() + fun reportSubscriptionSettingsRemoveFromDeviceClick() +} + +@ContributesBinding(AppScope::class) +class SubscriptionPixelSenderImpl @Inject constructor( + private val pixelSender: Pixel, +) : SubscriptionPixelSender { + + override fun reportSubscriptionSettingsSectionShown() = + fire(SETTINGS_SUBSCRIPTION_SECTION_SHOWN) + + override fun reportSubscriptionActive() = + fire(SUBSCRIPTION_ACTIVE) + + override fun reportOfferScreenShown() = + fire(OFFER_SCREEN_SHOWN) + + override fun reportOfferSubscribeClick() = + fire(OFFER_SUBSCRIBE_CLICK) + + override fun reportPurchaseFailureOther() = + fire(PURCHASE_FAILURE_OTHER) + + override fun reportPurchaseFailureStore() = + fire(PURCHASE_FAILURE_STORE) + + override fun reportPurchaseFailureBackend() = + fire(PURCHASE_FAILURE_BACKEND) + + override fun reportPurchaseFailureAccountCreation() = + fire(PURCHASE_FAILURE_ACCOUNT_CREATION) + + override fun reportPurchaseSuccess() = + fire(PURCHASE_SUCCESS) + + override fun reportOfferRestorePurchaseClick() = + fire(OFFER_RESTORE_PURCHASE_CLICK) + + override fun reportActivateSubscriptionEnterEmailClick() = + fire(ACTIVATE_SUBSCRIPTION_ENTER_EMAIL_CLICK) + + override fun reportActivateSubscriptionRestorePurchaseClick() = + fire(ACTIVATE_SUBSCRIPTION_RESTORE_PURCHASE_CLICK) + + override fun reportRestoreUsingEmailSuccess() = + fire(RESTORE_USING_EMAIL_SUCCESS) + + override fun reportRestoreUsingStoreSuccess() = + fire(RESTORE_USING_STORE_SUCCESS) + + override fun reportRestoreUsingStoreFailureSubscriptionNotFound() = + fire(RESTORE_USING_STORE_FAILURE_SUBSCRIPTION_NOT_FOUND) + + override fun reportRestoreUsingStoreFailureOther() = + fire(RESTORE_USING_STORE_FAILURE_OTHER) + + override fun reportRestoreAfterPurchaseAttemptSuccess() = + fire(RESTORE_AFTER_PURCHASE_ATTEMPT_SUCCESS) + + override fun reportSubscriptionActivated() = + fire(SUBSCRIPTION_ACTIVATED) + + override fun reportOnboardingAddDeviceClick() = + fire(ONBOARDING_ADD_DEVICE_CLICK) + + override fun reportSettingsAddDeviceClick() = + fire(SETTINGS_ADD_DEVICE_CLICK) + + override fun reportAddDeviceEnterEmailClick() = + fire(ADD_DEVICE_ENTER_EMAIL_CLICK) + + override fun reportOnboardingVpnClick() = + fire(ONBOARDING_VPN_CLICK) + + override fun reportOnboardingPirClick() = + fire(ONBOARDING_PIR_CLICK) + + override fun reportOnboardingIdtrClick() = + fire(ONBOARDING_IDTR_CLICK) + + override fun reportSubscriptionSettingsShown() = + fire(SUBSCRIPTION_SETTINGS_SHOWN) + + override fun reportAppSettingsPirClick() = + fire(APP_SETTINGS_PIR_CLICK) + + override fun reportAppSettingsIdtrClick() = + fire(APP_SETTINGS_IDTR_CLICK) + + override fun reportSubscriptionSettingsChangePlanOrBillingClick() = + fire(SUBSCRIPTION_SETTINGS_CHANGE_PLAN_OR_BILLING_CLICK) + + override fun reportSubscriptionSettingsRemoveFromDeviceClick() = + fire(SUBSCRIPTION_SETTINGS_REMOVE_FROM_DEVICE_CLICK) + + private fun fire(pixel: SubscriptionPixel) { + pixel.getPixelNames().forEach { (pixelType, pixelName) -> + pixelSender.fire(pixelName = pixelName, type = pixelType) + } + } +} diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionRefreshRetentionAtbPlugin.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionRefreshRetentionAtbPlugin.kt new file mode 100644 index 000000000000..978fb5f4f99b --- /dev/null +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionRefreshRetentionAtbPlugin.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2024 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.pixels + +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.statistics.api.RefreshRetentionAtbPlugin +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.subscriptions.impl.SubscriptionsManager +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@ContributesMultibinding(AppScope::class) +class SubscriptionRefreshRetentionAtbPlugin @Inject constructor( + @AppCoroutineScope private val coroutineScope: CoroutineScope, + private val subscriptionsManager: SubscriptionsManager, + private val pixelSender: SubscriptionPixelSender, +) : RefreshRetentionAtbPlugin { + + override fun onSearchRetentionAtbRefreshed() = Unit // no-op + + override fun onAppRetentionAtbRefreshed() { + coroutineScope.launch { + if (subscriptionsManager.hasSubscription()) { + pixelSender.reportSubscriptionActive() + } + } + } +} diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ItrSettingViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ItrSettingViewModel.kt index 86070893f4ee..0788b9c872bf 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ItrSettingViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ItrSettingViewModel.kt @@ -27,8 +27,10 @@ import com.duckduckgo.subscriptions.api.Product.ITR import com.duckduckgo.subscriptions.api.Subscriptions import com.duckduckgo.subscriptions.api.Subscriptions.EntitlementStatus.Found import com.duckduckgo.subscriptions.api.Subscriptions.EntitlementStatus.NotFound +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.settings.views.ItrSettingViewModel.Command.OpenItr import javax.inject.Inject +import javax.inject.Provider import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow @@ -38,9 +40,10 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch @SuppressLint("NoLifecycleObserver") // we don't observe app lifecycle -class ItrSettingViewModel( +class ItrSettingViewModel @Inject constructor( private val subscriptions: Subscriptions, private val dispatcherProvider: DispatcherProvider, + private val pixelSender: SubscriptionPixelSender, ) : ViewModel(), DefaultLifecycleObserver { sealed class Command { @@ -55,6 +58,7 @@ class ItrSettingViewModel( val viewState = _viewState.asStateFlow() fun onItr() { + pixelSender.reportAppSettingsIdtrClick() sendCommand(OpenItr) } @@ -77,13 +81,12 @@ class ItrSettingViewModel( @Suppress("UNCHECKED_CAST") class Factory @Inject constructor( - private val subscriptions: Subscriptions, - private val dispatcherProvider: DispatcherProvider, + private val itrSettingViewModel: Provider, ) : ViewModelProvider.NewInstanceFactory() { override fun create(modelClass: Class): T { return with(modelClass) { when { - isAssignableFrom(ItrSettingViewModel::class.java) -> ItrSettingViewModel(subscriptions, dispatcherProvider) + isAssignableFrom(ItrSettingViewModel::class.java) -> itrSettingViewModel.get() else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") } } as T diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/PirSettingViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/PirSettingViewModel.kt index e86678d761f9..52c6f88def75 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/PirSettingViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/PirSettingViewModel.kt @@ -27,8 +27,10 @@ import com.duckduckgo.subscriptions.api.Product.PIR import com.duckduckgo.subscriptions.api.Subscriptions import com.duckduckgo.subscriptions.api.Subscriptions.EntitlementStatus.Found import com.duckduckgo.subscriptions.api.Subscriptions.EntitlementStatus.NotFound +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.settings.views.PirSettingViewModel.Command.OpenPir import javax.inject.Inject +import javax.inject.Provider import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow @@ -38,9 +40,10 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch @SuppressLint("NoLifecycleObserver") // we don't observe app lifecycle -class PirSettingViewModel( +class PirSettingViewModel @Inject constructor( private val subscriptions: Subscriptions, private val dispatcherProvider: DispatcherProvider, + private val pixelSender: SubscriptionPixelSender, ) : ViewModel(), DefaultLifecycleObserver { sealed class Command { @@ -55,6 +58,7 @@ class PirSettingViewModel( val viewState = _viewState.asStateFlow() fun onPir() { + pixelSender.reportAppSettingsPirClick() sendCommand(OpenPir) } @@ -77,13 +81,12 @@ class PirSettingViewModel( @Suppress("UNCHECKED_CAST") class Factory @Inject constructor( - private val subscriptions: Subscriptions, - private val dispatcherProvider: DispatcherProvider, + private val pirSettingViewModel: Provider, ) : ViewModelProvider.NewInstanceFactory() { override fun create(modelClass: Class): T { return with(modelClass) { when { - isAssignableFrom(PirSettingViewModel::class.java) -> PirSettingViewModel(subscriptions, dispatcherProvider) + isAssignableFrom(PirSettingViewModel::class.java) -> pirSettingViewModel.get() else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") } } as T diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingView.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingView.kt index 3d15bbddac28..032ebdde02b9 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingView.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingView.kt @@ -18,10 +18,16 @@ package com.duckduckgo.subscriptions.impl.settings.views import android.annotation.SuppressLint import android.content.Context +import android.graphics.Rect import android.util.AttributeSet import android.view.MotionEvent +import android.view.View +import android.view.ViewTreeObserver.OnGlobalLayoutListener +import android.view.ViewTreeObserver.OnScrollChangedListener import android.widget.FrameLayout import android.widget.LinearLayout +import androidx.core.view.doOnAttach +import androidx.core.view.doOnDetach import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewTreeLifecycleOwner import androidx.lifecycle.findViewTreeViewModelStoreOwner @@ -36,6 +42,7 @@ import com.duckduckgo.navigation.api.GlobalActivityStarter import com.duckduckgo.subscriptions.impl.R import com.duckduckgo.subscriptions.impl.SubscriptionsConstants import com.duckduckgo.subscriptions.impl.databinding.ViewSettingsBinding +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.settings.views.ProSettingViewModel.Command import com.duckduckgo.subscriptions.impl.settings.views.ProSettingViewModel.Command.OpenBuyScreen import com.duckduckgo.subscriptions.impl.settings.views.ProSettingViewModel.Command.OpenSettings @@ -65,6 +72,9 @@ class ProSettingView @JvmOverloads constructor( @Inject lateinit var globalActivityStarter: GlobalActivityStarter + @Inject + lateinit var pixelSender: SubscriptionPixelSender + private var coroutineScope: CoroutineScope? = null private val binding: ViewSettingsBinding by viewBinding() @@ -91,6 +101,10 @@ class ProSettingView @JvmOverloads constructor( viewModel.viewState .onEach { renderView(it) } .launchIn(coroutineScope!!) + + binding.subscribeSecondary.doOnFullyVisible { + pixelSender.reportSubscriptionSettingsSectionShown() + } } override fun onDetachedFromWindow() { @@ -155,3 +169,51 @@ class SubscriptionSettingLayout @JvmOverloads constructor( return true } } + +private fun View.doOnFullyVisible(action: () -> Unit) { + val listener = object : OnGlobalLayoutListener, OnScrollChangedListener { + var actionInvoked = false + + override fun onGlobalLayout() { + onPotentialVisibilityChange() + } + + override fun onScrollChanged() { + onPotentialVisibilityChange() + } + + fun onPotentialVisibilityChange() { + if (!actionInvoked && isViewFullyVisible()) { + actionInvoked = true + action() + } + + if (actionInvoked) { + unregister() + } + } + + fun isViewFullyVisible(): Boolean { + val visibleRect = Rect() + val isGlobalVisible = getGlobalVisibleRect(visibleRect) + return isGlobalVisible && width == visibleRect.width() && height == visibleRect.height() + } + + fun register() { + viewTreeObserver.addOnGlobalLayoutListener(this) + viewTreeObserver.addOnScrollChangedListener(this) + } + + fun unregister() { + viewTreeObserver.removeOnGlobalLayoutListener(this) + viewTreeObserver.removeOnScrollChangedListener(this) + } + } + + doOnAttach { + listener.register() + doOnDetach { + listener.unregister() + } + } +} diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingViewModel.kt index b147c551ba85..92db577068c0 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/settings/views/ProSettingViewModel.kt @@ -27,6 +27,7 @@ import com.duckduckgo.subscriptions.impl.SubscriptionsManager import com.duckduckgo.subscriptions.impl.settings.views.ProSettingViewModel.Command.OpenBuyScreen import com.duckduckgo.subscriptions.impl.settings.views.ProSettingViewModel.Command.OpenSettings import javax.inject.Inject +import javax.inject.Provider import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow @@ -36,7 +37,7 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch @SuppressLint("NoLifecycleObserver") // we don't observe app lifecycle -class ProSettingViewModel( +class ProSettingViewModel @Inject constructor( private val subscriptionsManager: SubscriptionsManager, private val dispatcherProvider: DispatcherProvider, ) : ViewModel(), DefaultLifecycleObserver { @@ -78,13 +79,12 @@ class ProSettingViewModel( @Suppress("UNCHECKED_CAST") class Factory @Inject constructor( - private val subscriptionsManager: SubscriptionsManager, - private val dispatcherProvider: DispatcherProvider, + private val proSettingViewModel: Provider, ) : ViewModelProvider.NewInstanceFactory() { override fun create(modelClass: Class): T { return with(modelClass) { when { - isAssignableFrom(ProSettingViewModel::class.java) -> ProSettingViewModel(subscriptionsManager, dispatcherProvider) + isAssignableFrom(ProSettingViewModel::class.java) -> proSettingViewModel.get() else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") } } as T diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/AddDeviceViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/AddDeviceViewModel.kt index 30919a259157..de2abea2dce5 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/AddDeviceViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/AddDeviceViewModel.kt @@ -26,6 +26,7 @@ import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.subscriptions.impl.SubscriptionsData import com.duckduckgo.subscriptions.impl.SubscriptionsManager +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.ui.AddDeviceViewModel.Command.AddEmail import com.duckduckgo.subscriptions.impl.ui.AddDeviceViewModel.Command.Error import com.duckduckgo.subscriptions.impl.ui.AddDeviceViewModel.Command.ManageEmail @@ -43,6 +44,7 @@ import kotlinx.coroutines.launch class AddDeviceViewModel @Inject constructor( private val subscriptionsManager: SubscriptionsManager, private val dispatcherProvider: DispatcherProvider, + private val pixelSender: SubscriptionPixelSender, ) : ViewModel(), DefaultLifecycleObserver { private val command = Channel(1, DROP_OLDEST) @@ -69,6 +71,8 @@ class AddDeviceViewModel @Inject constructor( } fun useEmail() { + pixelSender.reportAddDeviceEnterEmailClick() + viewModelScope.launch(dispatcherProvider.io()) { val subs = subscriptionsManager.getSubscriptionData() if (subs is SubscriptionsData.Success) { diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModel.kt index 98c3f1bd25f9..2049dcda3904 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModel.kt @@ -24,6 +24,7 @@ import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.subscriptions.impl.RealSubscriptionsManager.Companion.SUBSCRIPTION_NOT_FOUND_ERROR import com.duckduckgo.subscriptions.impl.SubscriptionsData import com.duckduckgo.subscriptions.impl.SubscriptionsManager +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.Error import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.RestoreFromEmail import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.SubscriptionNotFound @@ -41,6 +42,7 @@ import kotlinx.coroutines.launch class RestoreSubscriptionViewModel @Inject constructor( private val subscriptionsManager: SubscriptionsManager, private val dispatcherProvider: DispatcherProvider, + private val pixelSender: SubscriptionPixelSender, ) : ViewModel() { private val command = Channel(1, DROP_OLDEST) @@ -53,20 +55,29 @@ class RestoreSubscriptionViewModel @Inject constructor( ) fun restoreFromStore() { + pixelSender.reportActivateSubscriptionRestorePurchaseClick() viewModelScope.launch(dispatcherProvider.io()) { when (val response = subscriptionsManager.recoverSubscriptionFromStore()) { is SubscriptionsData.Success -> { if (response.entitlements.isEmpty()) { + pixelSender.reportRestoreUsingStoreFailureSubscriptionNotFound() subscriptionsManager.signOut() command.send(SubscriptionNotFound) } else { + pixelSender.reportRestoreUsingStoreSuccess() command.send(Success) } } is SubscriptionsData.Failure -> { when (response.message) { - SUBSCRIPTION_NOT_FOUND_ERROR -> command.send(SubscriptionNotFound) - else -> command.send(Error(response.message)) + SUBSCRIPTION_NOT_FOUND_ERROR -> { + pixelSender.reportRestoreUsingStoreFailureSubscriptionNotFound() + command.send(SubscriptionNotFound) + } + else -> { + pixelSender.reportRestoreUsingStoreFailureOther() + command.send(Error(response.message)) + } } } } @@ -74,6 +85,7 @@ class RestoreSubscriptionViewModel @Inject constructor( } fun restoreFromEmail() { + pixelSender.reportActivateSubscriptionEnterEmailClick() viewModelScope.launch { command.send(RestoreFromEmail) } diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt index 0573004b38df..4428b3e6c877 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt @@ -34,6 +34,7 @@ import com.duckduckgo.subscriptions.impl.R.string import com.duckduckgo.subscriptions.impl.SubscriptionStatus.AutoRenewable import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.BASIC_SUBSCRIPTION import com.duckduckgo.subscriptions.impl.databinding.ActivitySubscriptionSettingsBinding +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.ui.AddDeviceActivity.Companion.AddDeviceScreenWithEmptyParams import com.duckduckgo.subscriptions.impl.ui.ChangePlanActivity.Companion.ChangePlanScreenWithEmptyParams import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsActivity.Companion.SubscriptionsSettingsScreenWithEmptyParams @@ -53,6 +54,9 @@ class SubscriptionSettingsActivity : DuckDuckGoActivity() { @Inject lateinit var globalActivityStarter: GlobalActivityStarter + @Inject + lateinit var pixelSender: SubscriptionPixelSender + private val viewModel: SubscriptionSettingsViewModel by bindViewModel() private val binding: ActivitySubscriptionSettingsBinding by viewBinding() @@ -76,6 +80,7 @@ class SubscriptionSettingsActivity : DuckDuckGoActivity() { }.launchIn(lifecycleScope) binding.addDevice.setClickListener { + pixelSender.reportSettingsAddDeviceClick() globalActivityStarter.start(this, AddDeviceScreenWithEmptyParams) } @@ -103,6 +108,10 @@ class SubscriptionSettingsActivity : DuckDuckGoActivity() { binding.faq.setClickListener { Toast.makeText(this, "This will take you to FAQs", Toast.LENGTH_SHORT).show() } + + if (savedInstanceState == null) { + pixelSender.reportSubscriptionSettingsShown() + } } override fun onDestroy() { @@ -121,15 +130,18 @@ class SubscriptionSettingsActivity : DuckDuckGoActivity() { when (viewState.platform?.lowercase()) { "apple", "ios" -> binding.changePlan.setClickListener { + pixelSender.reportSubscriptionSettingsChangePlanOrBillingClick() globalActivityStarter.start(this, ChangePlanScreenWithEmptyParams) } "stripe" -> { binding.changePlan.setClickListener { + pixelSender.reportSubscriptionSettingsChangePlanOrBillingClick() viewModel.goToStripe() } } else -> { binding.changePlan.setClickListener { + pixelSender.reportSubscriptionSettingsChangePlanOrBillingClick() val url = String.format(URL, BASIC_SUBSCRIPTION, applicationContext.packageName) val intent = Intent(Intent.ACTION_VIEW) intent.setData(Uri.parse(url)) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModel.kt index e7b6cfffbfd5..d210e03bf242 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModel.kt @@ -28,6 +28,7 @@ import com.duckduckgo.subscriptions.impl.Subscription import com.duckduckgo.subscriptions.impl.SubscriptionStatus import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN import com.duckduckgo.subscriptions.impl.SubscriptionsManager +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.Command.FinishSignOut import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.Command.GoToPortal import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.SubscriptionDuration.Monthly @@ -48,6 +49,7 @@ import kotlinx.coroutines.launch class SubscriptionSettingsViewModel @Inject constructor( private val subscriptionsManager: SubscriptionsManager, private val dispatcherProvider: DispatcherProvider, + private val pixelSender: SubscriptionPixelSender, ) : ViewModel(), DefaultLifecycleObserver { private val command = Channel(1, DROP_OLDEST) @@ -82,6 +84,8 @@ class SubscriptionSettingsViewModel @Inject constructor( } fun removeFromDevice() { + pixelSender.reportSubscriptionSettingsRemoveFromDeviceClick() + viewModelScope.launch { subscriptionsManager.signOut() command.send(FinishSignOut) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt index c5b7358af625..db4879d34859 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModel.kt @@ -38,6 +38,7 @@ import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN import com.duckduckgo.subscriptions.impl.SubscriptionsManager import com.duckduckgo.subscriptions.impl.billing.getPrice +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.repository.SubscriptionsRepository import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command.* import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.PurchaseStateView.Failure @@ -66,6 +67,7 @@ class SubscriptionWebViewViewModel @Inject constructor( private val subscriptionsManager: SubscriptionsManager, private val subscriptionsRepository: SubscriptionsRepository, private val networkProtectionWaitlist: NetworkProtectionWaitlist, + private val pixelSender: SubscriptionPixelSender, ) : ViewModel() { private val moshi = Moshi.Builder().add(JSONObjectAdapter()).build() @@ -121,6 +123,12 @@ class SubscriptionWebViewViewModel @Inject constructor( PIR -> GoToPIR else -> null } + when (commandToSend) { + GoToITR -> pixelSender.reportOnboardingIdtrClick() + is GoToNetP -> pixelSender.reportOnboardingVpnClick() + GoToPIR -> pixelSender.reportOnboardingPirClick() + else -> {} // no-op + } commandToSend?.let { command.send(commandToSend) } @@ -129,17 +137,22 @@ class SubscriptionWebViewViewModel @Inject constructor( private fun activateSubscription() { viewModelScope.launch(dispatcherProvider.io()) { if (subscriptionsManager.hasSubscription()) { + pixelSender.reportOnboardingAddDeviceClick() activateOnAnotherDevice() } else { + pixelSender.reportOfferRestorePurchaseClick() recoverSubscription() } } } private fun subscriptionSelected(data: JSONObject?) { + pixelSender.reportOfferSubscribeClick() + viewModelScope.launch(dispatcherProvider.io()) { val id = runCatching { data?.getString("id") }.getOrNull() if (id.isNullOrBlank()) { + pixelSender.reportPurchaseFailureOther() _currentPurchaseViewState.emit(currentPurchaseViewState.value.copy(purchaseState = Failure(""))) } else { command.send(SubscriptionSelected(id)) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionsWebViewActivity.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionsWebViewActivity.kt index 2b732f217319..122cd6a9b90f 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionsWebViewActivity.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionsWebViewActivity.kt @@ -75,6 +75,7 @@ import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.ACTIVATE_URL import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.BUY_URL import com.duckduckgo.subscriptions.impl.databinding.ActivitySubscriptionsWebviewBinding import com.duckduckgo.subscriptions.impl.pir.PirActivity.Companion.PirScreenWithEmptyParams +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.ui.AddDeviceActivity.Companion.AddDeviceScreenWithEmptyParams import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionActivity.Companion.RestoreSubscriptionScreenWithEmptyParams import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command @@ -145,6 +146,9 @@ class SubscriptionsWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD @Inject lateinit var downloadsFileActions: DownloadsFileActions + @Inject + lateinit var pixelSender: SubscriptionPixelSender + private val viewModel: SubscriptionWebViewViewModel by bindViewModel() private val binding: ActivitySubscriptionsWebviewBinding by viewBinding() @@ -245,6 +249,10 @@ class SubscriptionsWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD viewModel.currentPurchaseViewState.flowWithLifecycle(lifecycle, Lifecycle.State.STARTED).distinctUntilChanged().onEach { renderPurchaseState(it.purchaseState) }.launchIn(lifecycleScope) + + if (savedInstanceState == null && url == BUY_URL) { + pixelSender.reportOfferScreenShown() + } } override fun continueDownload(pendingFileDownload: PendingFileDownload) { 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 999a83800cc9..37925221518f 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 @@ -18,6 +18,7 @@ import com.duckduckgo.subscriptions.impl.SubscriptionsData.Failure import com.duckduckgo.subscriptions.impl.SubscriptionsData.Success import com.duckduckgo.subscriptions.impl.billing.BillingClientWrapper import com.duckduckgo.subscriptions.impl.billing.PurchaseState +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.repository.AuthRepository import com.duckduckgo.subscriptions.impl.repository.FakeAuthDataStore import com.duckduckgo.subscriptions.impl.repository.RealAuthRepository @@ -33,6 +34,7 @@ import com.duckduckgo.subscriptions.impl.services.SubscriptionsService import com.duckduckgo.subscriptions.impl.services.ValidateTokenResponse import com.duckduckgo.subscriptions.store.AuthDataStore import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import okhttp3.MediaType.Companion.toMediaTypeOrNull @@ -47,6 +49,7 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever import retrofit2.HttpException import retrofit2.Response @@ -65,6 +68,7 @@ class RealSubscriptionsManagerTest { private val billingClient: BillingClientWrapper = mock() private val billingBuilder: BillingFlowParams.Builder = mock() private val context: Context = mock() + private val pixelSender: SubscriptionPixelSender = mock() private lateinit var subscriptionsManager: SubscriptionsManager @Before @@ -82,6 +86,7 @@ class RealSubscriptionsManagerTest { context, TestScope(), coroutineRule.testDispatcherProvider, + pixelSender, ) } @@ -416,6 +421,7 @@ class RealSubscriptionsManagerTest { context, TestScope(), coroutineRule.testDispatcherProvider, + pixelSender, ) manager.hasSubscription.test { @@ -438,6 +444,7 @@ class RealSubscriptionsManagerTest { context, TestScope(), coroutineRule.testDispatcherProvider, + pixelSender, ) manager.hasSubscription.test { @@ -460,6 +467,7 @@ class RealSubscriptionsManagerTest { context, TestScope(), coroutineRule.testDispatcherProvider, + pixelSender, ) manager.hasSubscription.test { @@ -481,6 +489,7 @@ class RealSubscriptionsManagerTest { context, TestScope(), coroutineRule.testDispatcherProvider, + pixelSender, ) manager.hasSubscription.test { @@ -502,6 +511,7 @@ class RealSubscriptionsManagerTest { context, TestScope(), coroutineRule.testDispatcherProvider, + pixelSender, ) manager.hasSubscription.test { @@ -527,6 +537,7 @@ class RealSubscriptionsManagerTest { context, TestScope(), coroutineRule.testDispatcherProvider, + pixelSender, ) manager.currentPurchaseState.test { @@ -554,6 +565,7 @@ class RealSubscriptionsManagerTest { context, TestScope(), coroutineRule.testDispatcherProvider, + pixelSender, ) manager.currentPurchaseState.test { @@ -753,6 +765,7 @@ class RealSubscriptionsManagerTest { context, TestScope(), coroutineRule.testDispatcherProvider, + pixelSender, ) manager.signOut() verify(mockRepo).signOut() @@ -785,6 +798,7 @@ class RealSubscriptionsManagerTest { context, TestScope(), coroutineRule.testDispatcherProvider, + pixelSender, ) manager.hasSubscription.test { @@ -795,6 +809,96 @@ class RealSubscriptionsManagerTest { } } + @Test + fun whenPurchaseIsSuccessfulThenPixelIsSent() = runTest { + givenUserIsAuthenticated() + givenValidateTokenSucceedsWithEntitlements() + + whenever(billingClient.purchaseState).thenReturn(flowOf(PurchaseState.Purchased)) + + subscriptionsManager.currentPurchaseState.test { + assertTrue(awaitItem() is CurrentPurchase.InProgress) + assertTrue(awaitItem() is CurrentPurchase.Success) + + verify(pixelSender).reportPurchaseSuccess() + verify(pixelSender).reportSubscriptionActivated() + verifyNoMoreInteractions(pixelSender) + + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun whenSubscriptionIsRestoredOnPurchaseAttemptThenPixelIsSent() = runTest { + givenUserIsNotAuthenticated() + givenPurchaseStored() + givenPurchaseStoredIsValid() + givenValidateTokenSucceedsWithEntitlements() + givenAuthenticateSucceeds() + + subscriptionsManager.currentPurchaseState.test { + subscriptionsManager.purchase(mock(), mock(), "", false) + assertTrue(awaitItem() is CurrentPurchase.PreFlowInProgress) + assertTrue(awaitItem() is CurrentPurchase.Recovered) + + verify(pixelSender).reportRestoreAfterPurchaseAttemptSuccess() + verify(pixelSender).reportSubscriptionActivated() + verifyNoMoreInteractions(pixelSender) + + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun whenPurchaseFailsThenPixelIsSent() = runTest { + givenUserIsAuthenticated() + givenValidateTokenFails("failure") + + whenever(billingClient.purchaseState).thenReturn(flowOf(PurchaseState.Purchased)) + + subscriptionsManager.currentPurchaseState.test { + assertTrue(awaitItem() is CurrentPurchase.InProgress) + assertTrue(awaitItem() is CurrentPurchase.Failure) + + verify(pixelSender).reportPurchaseFailureBackend() + verifyNoMoreInteractions(pixelSender) + + cancelAndConsumeRemainingEvents() + } + } + + @Test + fun whenSubscriptionIsRecoveredFromStoreThenPixelIsSent() = runTest { + givenPurchaseStored() + givenPurchaseStoredIsValid() + givenValidateTokenSucceedsWithEntitlements() + givenAuthenticateSucceeds() + + val value = subscriptionsManager.recoverSubscriptionFromStore() + + assertTrue(value is Success) + verify(pixelSender).reportSubscriptionActivated() + verifyNoMoreInteractions(pixelSender) + } + + @Test + fun whenPurchaseFlowIfCreateAccountFailsThenPixelIsSent() = runTest { + givenUserIsNotAuthenticated() + givenCreateAccountFails() + + subscriptionsManager.currentPurchaseState.test { + subscriptionsManager.purchase(mock(), mock(), "", false) + assertTrue(awaitItem() is CurrentPurchase.PreFlowInProgress) + assertTrue(awaitItem() is CurrentPurchase.Failure) + + verify(pixelSender).reportPurchaseFailureAccountCreation() + verify(pixelSender).reportPurchaseFailureOther() + verifyNoMoreInteractions(pixelSender) + + cancelAndConsumeRemainingEvents() + } + } + private suspend fun givenUrlPortalSucceeds() { whenever(subscriptionsService.portal(any())).thenReturn(PortalResponse("example.com")) } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt index 56df478c39ba..2f07797d0f2a 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/messaging/SubscriptionMessagingInterfaceTest.kt @@ -8,6 +8,7 @@ import com.duckduckgo.js.messaging.api.JsMessageHelper import com.duckduckgo.js.messaging.api.JsRequestResponse import com.duckduckgo.subscriptions.impl.AuthToken import com.duckduckgo.subscriptions.impl.SubscriptionsManager +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.json.JSONObject @@ -31,11 +32,13 @@ class SubscriptionMessagingInterfaceTest { private val webView: WebView = mock() private val jsMessageHelper: JsMessageHelper = mock() private val subscriptionsManager: SubscriptionsManager = mock() + private val pixelSender: SubscriptionPixelSender = mock() private val messagingInterface = SubscriptionMessagingInterface( subscriptionsManager, jsMessageHelper, coroutineRule.testDispatcherProvider, TestScope(), + pixelSender, ) private val callback = object : JsMessageCallback() { @@ -233,6 +236,7 @@ class SubscriptionMessagingInterfaceTest { messagingInterface.process(message, "duckduckgo-android-messaging-secret") verifyNoInteractions(subscriptionsManager) + verifyNoInteractions(pixelSender) } @Test @@ -246,6 +250,8 @@ class SubscriptionMessagingInterfaceTest { messagingInterface.process(message, "duckduckgo-android-messaging-secret") verify(subscriptionsManager).authenticate("authToken") + verify(pixelSender).reportRestoreUsingEmailSuccess() + verify(pixelSender).reportSubscriptionActivated() } @Test @@ -259,6 +265,7 @@ class SubscriptionMessagingInterfaceTest { messagingInterface.process(message, "duckduckgo-android-messaging-secret") verifyNoInteractions(subscriptionsManager) + verifyNoInteractions(pixelSender) } @Test diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionPixelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionPixelTest.kt new file mode 100644 index 000000000000..ddd365d6ae24 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionPixelTest.kt @@ -0,0 +1,47 @@ +package com.duckduckgo.subscriptions.impl.pixels + +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.COUNT +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.DAILY +import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.UNIQUE +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Parameterized.Parameters + +@RunWith(Parameterized::class) +class SubscriptionPixelTest( + private val pixel: SubscriptionPixel, +) { + @Test + fun `pixel name has privacy pro namespace prefix`() { + pixel.getPixelNames().values.forEach { pixelName -> + assertTrue(pixelName.startsWith("m_privacy-pro_")) + } + } + + @Test + fun `pixel name has pixel type suffix`() { + pixel.getPixelNames().forEach { (pixelType, pixelName) -> + val expectedSuffix = when (pixelType) { + COUNT -> "_c" + DAILY -> "_d" + UNIQUE -> "_u" + } + + assertTrue(pixelName.endsWith(expectedSuffix)) + } + } + + @Test + fun `pixel names map is not empty`() { + assertTrue(pixel.getPixelNames().isNotEmpty()) + } + + companion object { + @JvmStatic + @Parameters(name = "{0}") + fun data(): Collection> = + SubscriptionPixel.entries.map { arrayOf(it) } + } +} diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionRefreshRetentionAtbPluginTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionRefreshRetentionAtbPluginTest.kt new file mode 100644 index 000000000000..86cd001528e3 --- /dev/null +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/pixels/SubscriptionRefreshRetentionAtbPluginTest.kt @@ -0,0 +1,44 @@ +package com.duckduckgo.subscriptions.impl.pixels + +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.subscriptions.impl.SubscriptionsManager +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class SubscriptionRefreshRetentionAtbPluginTest { + + @get:Rule + var coroutineRule = CoroutineTestRule() + + private val subscriptionsManager: SubscriptionsManager = mock() + private val pixelSender: SubscriptionPixelSender = mock() + + private val subject = SubscriptionRefreshRetentionAtbPlugin( + coroutineScope = coroutineRule.testScope, + subscriptionsManager = subscriptionsManager, + pixelSender = pixelSender, + ) + + @Test + fun `when subscription is active then pixel is sent`() = runTest { + whenever(subscriptionsManager.hasSubscription()).thenReturn(true) + + subject.onAppRetentionAtbRefreshed() + + verify(pixelSender).reportSubscriptionActive() + } + + @Test + fun `when subscription is not active then pixel is not sent`() = runTest { + whenever(subscriptionsManager.hasSubscription()).thenReturn(false) + + subject.onAppRetentionAtbRefreshed() + + verify(pixelSender, never()).reportSubscriptionActive() + } +} diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/settings/views/ItrSettingViewModelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/settings/views/ItrSettingViewModelTest.kt index 58e1d42ea771..783e2bec9447 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/settings/views/ItrSettingViewModelTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/settings/views/ItrSettingViewModelTest.kt @@ -6,6 +6,7 @@ import com.duckduckgo.subscriptions.api.Product.ITR import com.duckduckgo.subscriptions.api.Subscriptions import com.duckduckgo.subscriptions.api.Subscriptions.EntitlementStatus.Found import com.duckduckgo.subscriptions.api.Subscriptions.EntitlementStatus.NotFound +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.settings.views.ItrSettingViewModel.Command.OpenItr import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -14,6 +15,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @ExperimentalCoroutinesApi @@ -22,11 +24,12 @@ class ItrSettingViewModelTest { val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() private val subscriptions: Subscriptions = mock() + private val pixelSender: SubscriptionPixelSender = mock() private lateinit var viewModel: ItrSettingViewModel @Before fun before() { - viewModel = ItrSettingViewModel(subscriptions, coroutineTestRule.testDispatcherProvider) + viewModel = ItrSettingViewModel(subscriptions, coroutineTestRule.testDispatcherProvider, pixelSender) } @Test @@ -38,6 +41,12 @@ class ItrSettingViewModelTest { } } + @Test + fun whenOnItrThenPixelSent() = runTest { + viewModel.onItr() + verify(pixelSender).reportAppSettingsIdtrClick() + } + @Test fun whenOnResumeIfSubscriptionEmitViewState() = runTest { whenever(subscriptions.getEntitlementStatus(ITR)).thenReturn(Result.success(Found)) diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/settings/views/PirSettingViewModelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/settings/views/PirSettingViewModelTest.kt index b6ca0dceb448..227760a08866 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/settings/views/PirSettingViewModelTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/settings/views/PirSettingViewModelTest.kt @@ -6,6 +6,7 @@ import com.duckduckgo.subscriptions.api.Product.PIR import com.duckduckgo.subscriptions.api.Subscriptions import com.duckduckgo.subscriptions.api.Subscriptions.EntitlementStatus.Found import com.duckduckgo.subscriptions.api.Subscriptions.EntitlementStatus.NotFound +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.settings.views.PirSettingViewModel.Command.OpenPir import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -14,6 +15,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @ExperimentalCoroutinesApi @@ -22,11 +24,12 @@ class PirSettingViewModelTest { val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() private val subscriptions: Subscriptions = mock() + private val pixelSender: SubscriptionPixelSender = mock() private lateinit var viewModel: PirSettingViewModel @Before fun before() { - viewModel = PirSettingViewModel(subscriptions, coroutineTestRule.testDispatcherProvider) + viewModel = PirSettingViewModel(subscriptions, coroutineTestRule.testDispatcherProvider, pixelSender) } @Test @@ -38,6 +41,12 @@ class PirSettingViewModelTest { } } + @Test + fun whenOnPirThenPixelSent() = runTest { + viewModel.onPir() + verify(pixelSender).reportAppSettingsPirClick() + } + @Test fun whenOnResumeIfEntitlementPresentEmitViewState() = runTest { whenever(subscriptions.getEntitlementStatus(PIR)).thenReturn(Result.success(Found)) diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/AddDeviceViewModelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/AddDeviceViewModelTest.kt index 1358e6a6d6d4..f023a7ba8d2c 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/AddDeviceViewModelTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/AddDeviceViewModelTest.kt @@ -4,6 +4,7 @@ import app.cash.turbine.test import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.subscriptions.impl.SubscriptionsData import com.duckduckgo.subscriptions.impl.SubscriptionsManager +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.ui.AddDeviceViewModel.Command.AddEmail import com.duckduckgo.subscriptions.impl.ui.AddDeviceViewModel.Command.Error import com.duckduckgo.subscriptions.impl.ui.AddDeviceViewModel.Command.ManageEmail @@ -13,6 +14,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever class AddDeviceViewModelTest { @@ -21,11 +23,12 @@ class AddDeviceViewModelTest { val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() private val subscriptionsManager: SubscriptionsManager = mock() + private val pixelSender: SubscriptionPixelSender = mock() private lateinit var viewModel: AddDeviceViewModel @Before fun before() { - viewModel = AddDeviceViewModel(subscriptionsManager, coroutineTestRule.testDispatcherProvider) + viewModel = AddDeviceViewModel(subscriptionsManager, coroutineTestRule.testDispatcherProvider, pixelSender) } @Test @@ -100,4 +103,10 @@ class AddDeviceViewModelTest { assertNull(awaitItem().email) } } + + @Test + fun whenEnterEmailClickedThenPixelIsSent() = runTest { + viewModel.useEmail() + verify(pixelSender).reportAddDeviceEnterEmailClick() + } } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModelTest.kt index a239679710dc..8bfba3d86876 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModelTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/RestoreSubscriptionViewModelTest.kt @@ -5,6 +5,7 @@ import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.subscriptions.impl.RealSubscriptionsManager.Companion.SUBSCRIPTION_NOT_FOUND_ERROR import com.duckduckgo.subscriptions.impl.SubscriptionsData import com.duckduckgo.subscriptions.impl.SubscriptionsManager +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.services.Entitlement import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.Error import com.duckduckgo.subscriptions.impl.ui.RestoreSubscriptionViewModel.Command.RestoreFromEmail @@ -16,6 +17,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever class RestoreSubscriptionViewModelTest { @@ -24,11 +26,16 @@ class RestoreSubscriptionViewModelTest { val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() private val subscriptionsManager: SubscriptionsManager = mock() + private val pixelSender: SubscriptionPixelSender = mock() private lateinit var viewModel: RestoreSubscriptionViewModel @Before fun before() { - viewModel = RestoreSubscriptionViewModel(subscriptionsManager, coroutineTestRule.testDispatcherProvider) + viewModel = RestoreSubscriptionViewModel( + subscriptionsManager = subscriptionsManager, + dispatcherProvider = coroutineTestRule.testDispatcherProvider, + pixelSender = pixelSender, + ) } @Test @@ -95,4 +102,60 @@ class RestoreSubscriptionViewModelTest { assertTrue(result is Success) } } + + @Test + fun whenRestoreFromStoreClickThenPixelIsSent() = runTest { + viewModel.restoreFromStore() + verify(pixelSender).reportActivateSubscriptionRestorePurchaseClick() + } + + @Test + fun whenRestoreFromEmailClickThenPixelIsSent() = runTest { + viewModel.restoreFromEmail() + verify(pixelSender).reportActivateSubscriptionEnterEmailClick() + } + + @Test + fun whenRestoreFromStoreSuccessThenPixelIsSent() = runTest { + whenever(subscriptionsManager.recoverSubscriptionFromStore()).thenReturn( + SubscriptionsData.Success( + email = null, + externalId = "test", + entitlements = listOf(Entitlement(id = "test", product = "test", name = "test")), + ), + ) + + viewModel.restoreFromStore() + verify(pixelSender).reportRestoreUsingStoreSuccess() + } + + @Test + fun whenRestoreFromStoreFailsBecauseThereAreNoEntitlementsThenPixelIsSent() = runTest { + whenever(subscriptionsManager.recoverSubscriptionFromStore()).thenReturn( + SubscriptionsData.Success(email = null, externalId = "test", entitlements = emptyList()), + ) + + viewModel.restoreFromStore() + verify(pixelSender).reportRestoreUsingStoreFailureSubscriptionNotFound() + } + + @Test + fun whenRestoreFromStoreFailsBecauseThereIsNoSubscriptionThenPixelIsSent() = runTest { + whenever(subscriptionsManager.recoverSubscriptionFromStore()).thenReturn( + SubscriptionsData.Failure(SUBSCRIPTION_NOT_FOUND_ERROR), + ) + + viewModel.restoreFromStore() + verify(pixelSender).reportRestoreUsingStoreFailureSubscriptionNotFound() + } + + @Test + fun whenRestoreFromStoreFailsForOtherReasonThenPixelIsSent() = runTest { + whenever(subscriptionsManager.recoverSubscriptionFromStore()).thenReturn( + SubscriptionsData.Failure("bad stuff happened"), + ) + + viewModel.restoreFromStore() + verify(pixelSender).reportRestoreUsingStoreFailureOther() + } } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModelTest.kt index 0ad056f63f93..4f752f08a246 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModelTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModelTest.kt @@ -6,6 +6,7 @@ import com.duckduckgo.subscriptions.impl.Subscription import com.duckduckgo.subscriptions.impl.SubscriptionStatus.AutoRenewable import com.duckduckgo.subscriptions.impl.SubscriptionsConstants import com.duckduckgo.subscriptions.impl.SubscriptionsManager +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.Command.FinishSignOut import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.Command.GoToPortal import com.duckduckgo.subscriptions.impl.ui.SubscriptionSettingsViewModel.SubscriptionDuration.Monthly @@ -16,6 +17,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever class SubscriptionSettingsViewModelTest { @@ -24,11 +26,12 @@ class SubscriptionSettingsViewModelTest { val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() private val subscriptionsManager: SubscriptionsManager = mock() + private val pixelSender: SubscriptionPixelSender = mock() private lateinit var viewModel: SubscriptionSettingsViewModel @Before fun before() { - viewModel = SubscriptionSettingsViewModel(subscriptionsManager, coroutineTestRule.testDispatcherProvider) + viewModel = SubscriptionSettingsViewModel(subscriptionsManager, coroutineTestRule.testDispatcherProvider, pixelSender) } @Test @@ -115,4 +118,10 @@ class SubscriptionSettingsViewModelTest { cancelAndConsumeRemainingEvents() } } + + @Test + fun whenRemoveFromDeviceThenPixelIsSent() = runTest { + viewModel.removeFromDevice() + verify(pixelSender).reportSubscriptionSettingsRemoveFromDeviceClick() + } } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt index 2b1ee48893f5..87998cfd2da3 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionWebViewViewModelTest.kt @@ -14,6 +14,7 @@ import com.duckduckgo.subscriptions.impl.SubscriptionsConstants import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN import com.duckduckgo.subscriptions.impl.SubscriptionsManager +import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender import com.duckduckgo.subscriptions.impl.repository.SubscriptionsRepository import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Command import com.duckduckgo.subscriptions.impl.ui.SubscriptionWebViewViewModel.Companion @@ -31,6 +32,7 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @RunWith(AndroidJUnit4::class) @@ -43,6 +45,7 @@ class SubscriptionWebViewViewModelTest { private val subscriptionsManager: SubscriptionsManager = mock() private val subscriptionsRepository: SubscriptionsRepository = mock() private val networkProtectionWaitlist: NetworkProtectionWaitlist = mock() + private val pixelSender: SubscriptionPixelSender = mock() private lateinit var viewModel: SubscriptionWebViewViewModel @@ -54,6 +57,7 @@ class SubscriptionWebViewViewModelTest { subscriptionsManager, subscriptionsRepository, networkProtectionWaitlist, + pixelSender, ) } @@ -243,6 +247,77 @@ class SubscriptionWebViewViewModelTest { } } + @Test + fun whenSubscriptionSelectedThenPixelIsSent() = runTest { + viewModel.processJsCallbackMessage( + featureName = "test", + method = "subscriptionSelected", + id = "id", + data = JSONObject("""{"id":"myId"}"""), + ) + verify(pixelSender).reportOfferSubscribeClick() + } + + @Test + fun whenRestorePurchaseClickedThenPixelIsSent() = runTest { + whenever(subscriptionsManager.hasSubscription()).thenReturn(false) + viewModel.processJsCallbackMessage( + featureName = "test", + method = "activateSubscription", + id = null, + data = null, + ) + verify(pixelSender).reportOfferRestorePurchaseClick() + } + + @Test + fun whenActivateOnAnotherDeviceClickedThenPixelIsSent() = runTest { + whenever(subscriptionsManager.hasSubscription()).thenReturn(true) + viewModel.processJsCallbackMessage( + featureName = "test", + method = "activateSubscription", + id = null, + data = null, + ) + verify(pixelSender).reportOnboardingAddDeviceClick() + } + + @Test + fun whenFeatureSelectedAndFeatureIsNetPThenPixelSent() = runTest { + whenever(subscriptionsManager.hasSubscription()).thenReturn(false) + viewModel.processJsCallbackMessage( + featureName = "test", + method = "featureSelected", + id = null, + data = JSONObject("""{"feature":"${SubscriptionsConstants.NETP}"}"""), + ) + verify(pixelSender).reportOnboardingVpnClick() + } + + @Test + fun whenFeatureSelectedAndFeatureIsItrThenPixelIsSent() = runTest { + whenever(subscriptionsManager.hasSubscription()).thenReturn(false) + viewModel.processJsCallbackMessage( + featureName = "test", + method = "featureSelected", + id = null, + data = JSONObject("""{"feature":"${SubscriptionsConstants.ITR}"}"""), + ) + verify(pixelSender).reportOnboardingIdtrClick() + } + + @Test + fun whenFeatureSelectedAndFeatureIsPirThenPixelIsSent() = runTest { + whenever(subscriptionsManager.hasSubscription()).thenReturn(false) + viewModel.processJsCallbackMessage( + featureName = "test", + method = "featureSelected", + id = null, + data = JSONObject("""{"feature":"${SubscriptionsConstants.PIR}"}"""), + ) + verify(pixelSender).reportOnboardingPirClick() + } + private fun getSubscriptionOfferDetails(planId: String): SubscriptionOfferDetails { val subscriptionOfferDetails: SubscriptionOfferDetails = mock() whenever(subscriptionOfferDetails.basePlanId).thenReturn(planId)