From dd3bac45ed1726ff14ac2dddd973afc8ea05e20d Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Wed, 16 Apr 2025 12:50:34 +0100 Subject: [PATCH 1/2] Parse new experiment metadata from subscriptionSelected message --- .../impl/SubscriptionsManager.kt | 29 +++++++++++++++++-- .../impl/ui/SubscriptionWebViewViewModel.kt | 16 ++++++++-- .../impl/ui/SubscriptionsWebViewActivity.kt | 11 +++++-- 3 files changed, 47 insertions(+), 9 deletions(-) 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 75d8520224de..b8179dd434a5 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 @@ -106,12 +106,14 @@ interface SubscriptionsManager { suspend fun getSubscriptionOffer(): List /** - * Launches the purchase flow for a given combination of plan id and offer id + * Launches the purchase flow for a given combination of plan id, offer id and front-end experiment details */ suspend fun purchase( activity: Activity, planId: String, offerId: String?, + experimentName: String?, + experimentCohort: String?, ) /** @@ -271,6 +273,9 @@ class RealSubscriptionsManager @Inject constructor( private var removeExpiredSubscriptionOnCancelledPurchase: Boolean = false private var purchaseFlowStartedUsingRestoredAccount: Boolean = false + // Indicates whether the user is part of any FE experiment at the time of purchase + private var experimentAssigned: Experiment? = null + override suspend fun isSignedIn(): Boolean { return isSignedInV1() || isSignedInV2() } @@ -424,9 +429,14 @@ class RealSubscriptionsManager @Inject constructor( purchaseToken: String, ): Boolean { var experimentName: String? = null - val cohort: String? = privacyProFeature.get().privacyProFreeTrialJan25().getCohort()?.name - if (cohort != null) { + var cohort: String? = privacyProFeature.get().privacyProFreeTrialJan25().getCohort()?.name + if (cohort != null) { // Android experiment experimentName = "privacyProFreeTrialJan25" + } else { + experimentAssigned?.let { // FE experiment details + cohort = it.cohort + experimentName = it.name + } } return try { val confirmationResponse = subscriptionsService.confirm( @@ -807,6 +817,8 @@ class RealSubscriptionsManager @Inject constructor( activity: Activity, planId: String, offerId: String?, + experimentName: String?, + experimentCohort: String?, ) { try { _currentPurchaseState.emit(CurrentPurchase.PreFlowInProgress) @@ -860,6 +872,12 @@ class RealSubscriptionsManager @Inject constructor( } } + experimentAssigned = if (experimentCohort.isNullOrEmpty() || experimentName.isNullOrEmpty()) { + null + } else { + Experiment(experimentName, experimentCohort) + } + purchaseFlowStartedUsingRestoredAccount = restoredAccount logcat(LogPriority.DEBUG) { "Subs: external id is ${authRepository.getAccount()!!.externalId}" } @@ -1120,3 +1138,8 @@ data class ValidatedTokenPair( val refreshToken: String, val refreshTokenClaims: RefreshTokenClaims, ) + +data class Experiment( + val name: String, + val cohort: String, +) 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 3056c453457e..8dce99a1441c 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 @@ -224,18 +224,26 @@ class SubscriptionWebViewViewModel @Inject constructor( viewModelScope.launch(dispatcherProvider.io()) { val id = runCatching { data?.getString("id") }.getOrNull() val offerId = runCatching { data?.getString("offerId") }.getOrNull() + val experimentName = runCatching { data?.getJSONObject("experiment")?.getString("name") }.getOrNull() + val experimentCohort = runCatching { data?.getJSONObject("experiment")?.getString("cohort") }.getOrNull() if (id.isNullOrBlank()) { pixelSender.reportPurchaseFailureOther() _currentPurchaseViewState.emit(currentPurchaseViewState.value.copy(purchaseState = Failure)) } else { - command.send(SubscriptionSelected(id, offerId)) + command.send(SubscriptionSelected(id, offerId, experimentName, experimentCohort)) } } } - fun purchaseSubscription(activity: Activity, planId: String, offerId: String?) { + fun purchaseSubscription( + activity: Activity, + planId: String, + offerId: String?, + experimentName: String?, + experimentCohort: String?, + ) { viewModelScope.launch(dispatcherProvider.io()) { - subscriptionsManager.purchase(activity, planId, offerId) + subscriptionsManager.purchase(activity, planId, offerId, experimentName, experimentCohort) } } @@ -411,6 +419,8 @@ class SubscriptionWebViewViewModel @Inject constructor( data class SubscriptionSelected( val id: String, val offerId: String?, + val experimentName: String?, + val experimentCohort: String?, ) : Command() data object RestoreSubscription : Command() data object GoToITR : Command() 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 f07c20ed77bf..54c90a63ec4f 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 @@ -417,7 +417,7 @@ class SubscriptionsWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD is BackToSettings, BackToSettingsActivateSuccess -> backToSettings() is SendJsEvent -> sendJsEvent(command.event) is SendResponseToJs -> sendResponseToJs(command.data) - is SubscriptionSelected -> selectSubscription(command.id, command.offerId) + is SubscriptionSelected -> selectSubscription(command.id, command.offerId, command.experimentName, command.experimentCohort) is RestoreSubscription -> restoreSubscription() is GoToITR -> goToITR() is GoToPIR -> goToPIR() @@ -517,8 +517,13 @@ class SubscriptionsWebViewActivity : DuckDuckGoActivity(), DownloadConfirmationD .show() } - private fun selectSubscription(id: String, offerId: String?) { - viewModel.purchaseSubscription(this, id, offerId) + private fun selectSubscription( + id: String, + offerId: String?, + experimentName: String?, + experimentCohort: String?, + ) { + viewModel.purchaseSubscription(this, id, offerId, experimentName, experimentCohort) } private fun sendResponseToJs(data: JsCallbackData) { From 7da9721398be7f5569a7e620ee1e759ba01b1eaa Mon Sep 17 00:00:00 2001 From: Noelia Alcala Date: Thu, 17 Apr 2025 00:05:54 +0100 Subject: [PATCH 2/2] Fix tests --- .../impl/RealSubscriptionsManagerTest.kt | 54 ++++++++++++------- 1 file changed, 35 insertions(+), 19 deletions(-) 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 6cd255d76363..0289f1662c5f 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 @@ -319,7 +319,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenCreateAccountSucceeds() val accountExternalId = authDataStore.externalId - subscriptionsManager.purchase(activity = mock(), planId = "", offerId = null) + purchase() if (authApiV2Enabled) { verify(authClient).authorize(any()) @@ -337,7 +337,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenUserIsNotSignedIn() givenCreateAccountSucceeds() - subscriptionsManager.purchase(mock(), planId = "", offerId = null) + purchase() if (authApiV2Enabled) { verify(authClient).authorize(any()) @@ -355,7 +355,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { whenever(emailManager.getToken()).thenReturn("emailToken") givenUserIsNotSignedIn() - subscriptionsManager.purchase(mock(), planId = "", offerId = null) + purchase() verify(authService).createAccount("Bearer emailToken") } @@ -366,7 +366,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenCreateAccountFails() subscriptionsManager.currentPurchaseState.test { - subscriptionsManager.purchase(mock(), planId = "", offerId = null) + purchase() assertTrue(awaitItem() is CurrentPurchase.PreFlowInProgress) assertTrue(awaitItem() is CurrentPurchase.Failure) cancelAndConsumeRemainingEvents() @@ -380,7 +380,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenValidateTokenSucceedsNoEntitlements() givenAccessTokenSucceeds() - subscriptionsManager.purchase(mock(), planId = "", offerId = null) + purchase() verify(playBillingManager).launchBillingFlow(any(), any(), externalId = eq("1234"), isNull()) } @@ -393,7 +393,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenSubscriptionSucceedsWithoutEntitlements(status = "Expired") givenAccessTokenSucceeds() - subscriptionsManager.purchase(mock(), "", null) + purchase() verify(playBillingManager).launchBillingFlow(any(), any(), externalId = eq("1234"), isNull()) } @@ -407,7 +407,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenAccessTokenSucceeds() subscriptionsManager.currentPurchaseState.test { - subscriptionsManager.purchase(mock(), planId = "", offerId = null) + purchase() assertTrue(awaitItem() is CurrentPurchase.PreFlowInProgress) verify(playBillingManager, never()).launchBillingFlow(any(), any(), any(), isNull()) assertTrue(awaitItem() is CurrentPurchase.Recovered) @@ -423,7 +423,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenStoreLoginFails() subscriptionsManager.currentPurchaseState.test { - subscriptionsManager.purchase(mock(), planId = "", offerId = null) + purchase() assertTrue(awaitItem() is CurrentPurchase.PreFlowInProgress) assertTrue(awaitItem() is CurrentPurchase.Failure) cancelAndConsumeRemainingEvents() @@ -436,7 +436,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenUserIsSignedIn() - subscriptionsManager.purchase(mock(), planId = "", offerId = null) + purchase() verify(authService).validateToken(any()) } @@ -447,7 +447,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenSubscriptionSucceedsWithoutEntitlements(status = "Expired") subscriptionsManager.currentPurchaseState.test { - subscriptionsManager.purchase(mock(), planId = "", offerId = null) + purchase() assertTrue(awaitItem() is CurrentPurchase.PreFlowInProgress) verify(playBillingManager).launchBillingFlow(any(), any(), externalId = eq("1234"), isNull()) assertTrue(awaitItem() is CurrentPurchase.PreFlowFinished) @@ -461,7 +461,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenCreateAccountFails() subscriptionsManager.currentPurchaseState.test { - subscriptionsManager.purchase(mock(), planId = "", offerId = null) + purchase() assertTrue(awaitItem() is CurrentPurchase.PreFlowInProgress) assertTrue(awaitItem() is CurrentPurchase.Failure) cancelAndConsumeRemainingEvents() @@ -473,7 +473,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenUserIsSignedIn() givenValidateTokenFails("failure") - subscriptionsManager.purchase(mock(), "", null) + purchase() verify(authService, never()).createAccount(any()) } @@ -485,7 +485,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenSubscriptionSucceedsWithoutEntitlements() givenAccessTokenSucceeds() - subscriptionsManager.purchase(mock(), planId = "", offerId = null) + purchase() if (authApiV2Enabled) { assertEquals(FAKE_ACCESS_TOKEN_V2, authDataStore.accessTokenV2) assertEquals(FAKE_REFRESH_TOKEN_V2, authDataStore.refreshTokenV2) @@ -507,7 +507,8 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenSubscriptionSucceedsWithoutEntitlements() givenAccessTokenSucceeds() - subscriptionsManager.purchase(mock(), planId = "", offerId = null) + purchase() + subscriptionsManager.isSignedIn.test { assertTrue(awaitItem()) if (authApiV2Enabled) { @@ -530,7 +531,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenUserIsSignedIn() givenSubscriptionFails(httpResponseCode = 400) - subscriptionsManager.purchase(mock(), planId = "", offerId = null) + purchase() verify(playBillingManager).launchBillingFlow(any(), any(), any(), isNull()) } @@ -540,7 +541,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenUserIsSignedIn() givenSubscriptionFails(httpResponseCode = 404) - subscriptionsManager.purchase(mock(), planId = "", offerId = null) + purchase() verify(playBillingManager).launchBillingFlow(any(), any(), any(), isNull()) } @@ -1171,7 +1172,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenAccessTokenSucceeds() subscriptionsManager.currentPurchaseState.test { - subscriptionsManager.purchase(mock(), planId = "", offerId = null) + purchase() assertTrue(awaitItem() is CurrentPurchase.PreFlowInProgress) assertTrue(awaitItem() is CurrentPurchase.Recovered) @@ -1197,7 +1198,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { whenever(playBillingManager.purchaseState).thenReturn(purchaseState) subscriptionsManager.currentPurchaseState.test { - subscriptionsManager.purchase(mock(), planId = "", offerId = null) + purchase() assertTrue(awaitItem() is CurrentPurchase.PreFlowInProgress) assertTrue(awaitItem() is CurrentPurchase.PreFlowFinished) @@ -1241,7 +1242,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { givenCreateAccountFails() subscriptionsManager.currentPurchaseState.test { - subscriptionsManager.purchase(mock(), planId = "", offerId = null) + purchase() assertTrue(awaitItem() is CurrentPurchase.PreFlowInProgress) assertTrue(awaitItem() is CurrentPurchase.Failure) @@ -1758,6 +1759,21 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { whenever(playBillingManager.products).thenReturn(listOf(productDetails)) } + private suspend fun purchase( + planId: String = "", + offerId: String? = null, + experimentName: String? = null, + experimentCohort: String? = null, + ) { + subscriptionsManager.purchase( + mock(), + planId = planId, + offerId = offerId, + experimentCohort = experimentCohort, + experimentName = experimentName, + ) + } + @SuppressLint("DenyListedApi") private fun givenIsLaunchedRow(value: Boolean) { privacyProFeature.isLaunchedROW().setRawStoredState(State(remoteEnableState = value))