From be5132b994ddf6148dfb438fd20539ff3f90ea9b Mon Sep 17 00:00:00 2001 From: Marcos Date: Fri, 9 Apr 2021 10:03:16 +0100 Subject: [PATCH] Re-enable use our app experiment (#1156) --- .../app/browser/BrowserTabViewModelTest.kt | 8 -- .../browser/shortcut/ShortcutReceiverTest.kt | 68 ++++++++- .../AndroidNotificationSchedulerTest.kt | 136 +++++++++++++++++- .../onboarding/store/AppUserStageStoreTest.kt | 59 +++++++- .../app/statistics/VariantManagerTest.kt | 31 ++-- .../systemsearch/SystemSearchViewModelTest.kt | 2 +- .../app/browser/BrowserTabFragment.kt | 5 +- .../app/browser/BrowserTabViewModel.kt | 4 - .../app/browser/shortcut/ShortcutReceiver.kt | 24 +++- .../java/com/duckduckgo/app/cta/ui/Cta.kt | 2 +- .../com/duckduckgo/app/cta/ui/CtaViewModel.kt | 4 +- .../duckduckgo/app/di/NotificationModule.kt | 9 +- .../java/com/duckduckgo/app/job/JobCleaner.kt | 3 +- .../AndroidNotificationScheduler.kt | 62 +++++++- .../app/onboarding/store/UserStageStore.kt | 32 ++++- .../app/statistics/VariantManager.kt | 13 +- 16 files changed, 409 insertions(+), 53 deletions(-) diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt index e73fe89580c3..f6434a8f71a0 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -1819,14 +1819,6 @@ class BrowserTabViewModelTest { verify(mockPixel).fire(cta.shownPixel!!, cta.pixelShownParameters()) } - @Test - fun whenManualCtaShownThenFirePixel() { - val cta = HomePanelCta.Survey(Survey("abc", "http://example.com", daysInstalled = 1, status = Survey.Status.SCHEDULED)) - - testee.onManualCtaShown(cta) - verify(mockPixel).fire(cta.shownPixel!!, cta.pixelShownParameters()) - } - @Test fun whenRegisterDaxBubbleCtaDismissedThenRegisterInDatabase() = coroutineRule.runBlocking { val cta = DaxBubbleCta.DaxIntroCta(mockOnboardingStore, mockAppInstallStore) diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/shortcut/ShortcutReceiverTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/shortcut/ShortcutReceiverTest.kt index 127a5081209d..1a796dba2d8d 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/shortcut/ShortcutReceiverTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/shortcut/ShortcutReceiverTest.kt @@ -17,28 +17,65 @@ package com.duckduckgo.app.browser.shortcut import android.content.Intent +import com.duckduckgo.app.CoroutineTestRule +import com.duckduckgo.app.global.events.db.UserEventKey import com.duckduckgo.app.global.events.db.UserEventsStore import com.duckduckgo.app.global.useourapp.UseOurAppDetector import com.duckduckgo.app.pixels.AppPixelName +import com.duckduckgo.app.runBlocking +import com.duckduckgo.app.statistics.Variant +import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.pixels.Pixel import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.ExperimentalCoroutinesApi import org.junit.Before +import org.junit.Rule import org.junit.Test +@ExperimentalCoroutinesApi class ShortcutReceiverTest { + @get:Rule + var coroutinesTestRule = CoroutineTestRule() + private val mockUserEventsStore: UserEventsStore = mock() private val mockPixel: Pixel = mock() + private val mockVariantManager: VariantManager = mock() private lateinit var testee: ShortcutReceiver @Before fun before() { - testee = ShortcutReceiver(UseOurAppDetector(mockUserEventsStore), mockPixel) + testee = ShortcutReceiver(UseOurAppDetector(mockUserEventsStore), mockPixel, mockUserEventsStore, coroutinesTestRule.testDispatcherProvider, mockVariantManager) + } + + @Test + fun whenIntentReceivedIfUrlIsFromUseOurAppDomainAndVariantIsInAppUsageThenRegisterTimestamp() = coroutinesTestRule.runBlocking { + setInAppUsageVariant() + val intent = Intent() + intent.putExtra(ShortcutBuilder.SHORTCUT_URL_ARG, "https://facebook.com") + intent.putExtra(ShortcutBuilder.SHORTCUT_TITLE_ARG, "Title") + testee.onReceive(null, intent) + + verify(mockUserEventsStore).registerUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED) + } + + @Test + fun whenIntentReceivedIfUrlIsFromUseOurAppDomainAndVariantIsNotInAppUsageThenDoNotRegisterTimestamp() = coroutinesTestRule.runBlocking { + setDefaultVariant() + val intent = Intent() + intent.putExtra(ShortcutBuilder.SHORTCUT_URL_ARG, "https://facebook.com") + intent.putExtra(ShortcutBuilder.SHORTCUT_TITLE_ARG, "Title") + testee.onReceive(null, intent) + + verify(mockUserEventsStore, never()).registerUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED) } @Test fun whenIntentReceivedIfUrlContainsUseOurAppDomainThenFirePixel() { + setDefaultVariant() val intent = Intent() intent.putExtra(ShortcutBuilder.SHORTCUT_URL_ARG, "https://facebook.com") intent.putExtra(ShortcutBuilder.SHORTCUT_TITLE_ARG, "Title") @@ -47,8 +84,20 @@ class ShortcutReceiverTest { verify(mockPixel).fire(AppPixelName.USE_OUR_APP_SHORTCUT_ADDED) } + @Test + fun whenIntentReceivedIfUrlIsNotFromUseOurAppDomainThenDoNotRegisterEvent() = coroutinesTestRule.runBlocking { + setDefaultVariant() + val intent = Intent() + intent.putExtra(ShortcutBuilder.SHORTCUT_URL_ARG, "www.example.com") + intent.putExtra(ShortcutBuilder.SHORTCUT_TITLE_ARG, "Title") + testee.onReceive(null, intent) + + verify(mockUserEventsStore, never()).registerUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED) + } + @Test fun whenIntentReceivedIfUrlIsNotFromUseOurAppDomainThenFireShortcutAddedPixel() { + setDefaultVariant() val intent = Intent() intent.putExtra(ShortcutBuilder.SHORTCUT_URL_ARG, "www.example.com") intent.putExtra(ShortcutBuilder.SHORTCUT_TITLE_ARG, "Title") @@ -57,4 +106,21 @@ class ShortcutReceiverTest { verify(mockPixel).fire(AppPixelName.SHORTCUT_ADDED) } + private fun setDefaultVariant() { + whenever(mockVariantManager.getVariant()).thenReturn(VariantManager.DEFAULT_VARIANT) + } + + private fun setInAppUsageVariant() { + whenever(mockVariantManager.getVariant()).thenReturn( + Variant( + "test", + features = listOf( + VariantManager.VariantFeature.InAppUsage, + VariantManager.VariantFeature.RemoveDay1AndDay3Notifications, + VariantManager.VariantFeature.KillOnboarding + ), + filterBy = { true } + ) + ) + } } diff --git a/app/src/androidTest/java/com/duckduckgo/app/notification/AndroidNotificationSchedulerTest.kt b/app/src/androidTest/java/com/duckduckgo/app/notification/AndroidNotificationSchedulerTest.kt index dbc9e324fde2..9f52de9e79bf 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/notification/AndroidNotificationSchedulerTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/notification/AndroidNotificationSchedulerTest.kt @@ -29,6 +29,9 @@ import com.duckduckgo.app.CoroutineTestRule import com.duckduckgo.app.notification.NotificationScheduler.ClearDataNotificationWorker import com.duckduckgo.app.notification.NotificationScheduler.PrivacyNotificationWorker import com.duckduckgo.app.notification.model.SchedulableNotification +import com.duckduckgo.app.statistics.Variant +import com.duckduckgo.app.statistics.VariantManager +import com.duckduckgo.app.statistics.VariantManager.Companion.DEFAULT_VARIANT import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.whenever import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -47,6 +50,8 @@ class AndroidNotificationSchedulerTest { private val clearNotification: SchedulableNotification = mock() private val privacyNotification: SchedulableNotification = mock() + private val useOurAppNotification: SchedulableNotification = mock() + private val variantManager: VariantManager = mock() private val context = InstrumentationRegistry.getInstrumentation().targetContext private lateinit var workManager: WorkManager @@ -59,7 +64,9 @@ class AndroidNotificationSchedulerTest { testee = NotificationScheduler( workManager, clearNotification, - privacyNotification + privacyNotification, + useOurAppNotification, + variantManager ) } @@ -76,6 +83,7 @@ class AndroidNotificationSchedulerTest { @Test fun whenPrivacyNotificationClearDataCanShowThenPrivacyNotificationIsScheduled() = runBlocking { + setDefaultVariant() whenever(privacyNotification.canShow()).thenReturn(true) whenever(clearNotification.canShow()).thenReturn(true) testee.scheduleNextNotification() @@ -85,6 +93,7 @@ class AndroidNotificationSchedulerTest { @Test fun whenPrivacyNotificationCanShowButClearDataCannotThenPrivacyNotificationIsScheduled() = runBlocking { + setDefaultVariant() whenever(privacyNotification.canShow()).thenReturn(true) whenever(clearNotification.canShow()).thenReturn(false) testee.scheduleNextNotification() @@ -94,6 +103,7 @@ class AndroidNotificationSchedulerTest { @Test fun whenPrivacyNotificationCannotShowAndClearNotificationCanShowThenClearNotificationIsScheduled() = runBlocking { + setDefaultVariant() whenever(privacyNotification.canShow()).thenReturn(false) whenever(clearNotification.canShow()).thenReturn(true) testee.scheduleNextNotification() @@ -103,6 +113,7 @@ class AndroidNotificationSchedulerTest { @Test fun whenPrivacyNotificationAndClearNotificationCannotShowThenNoNotificationScheduled() = runBlocking { + setDefaultVariant() whenever(privacyNotification.canShow()).thenReturn(false) whenever(clearNotification.canShow()).thenReturn(false) testee.scheduleNextNotification() @@ -110,6 +121,129 @@ class AndroidNotificationSchedulerTest { assertNoNotificationScheduled() } + @Test + fun whenInAppUsageVariantAndUseOurAppNotificationCanShowThenNotificationScheduled() = runBlocking { + givenNoInactiveUserNotifications() + setInAppUsageVariant() + whenever(useOurAppNotification.canShow()).thenReturn(true) + + testee.scheduleNextNotification() + + assertNotificationScheduled(NotificationScheduler.UseOurAppNotificationWorker::class.jvmName, NotificationScheduler.USE_OUR_APP_WORK_REQUEST_TAG) + } + + @Test + fun whenInAppUsageVariantUseOurAppNotificationCannotShowThenNoNotificationScheduled() = runBlocking { + givenNoInactiveUserNotifications() + setInAppUsageVariant() + whenever(useOurAppNotification.canShow()).thenReturn(false) + + testee.scheduleNextNotification() + + assertNoNotificationScheduled(NotificationScheduler.USE_OUR_APP_WORK_REQUEST_TAG) + } + + @Test + fun whenInAppUsageSecondControlVariantThenNoNotificationScheduled() = runBlocking { + setInAppUsageSecondControlVariant() + whenever(useOurAppNotification.canShow()).thenReturn(true) + + testee.scheduleNextNotification() + + assertNoNotificationScheduled(NotificationScheduler.USE_OUR_APP_WORK_REQUEST_TAG) + } + + @Test + fun whenInAppUsageControlVariantThenNoNotificationScheduled() = runBlocking { + givenNoInactiveUserNotifications() + setInAppUsageControlVariant() + whenever(useOurAppNotification.canShow()).thenReturn(true) + + testee.scheduleNextNotification() + + assertNoNotificationScheduled(NotificationScheduler.USE_OUR_APP_WORK_REQUEST_TAG) + } + + @Test + fun whenInAppUsageControlVariantAndPrivacyNotificationClearDataCanShowThenPrivacyNotificationIsScheduled() = runBlocking { + setInAppUsageControlVariant() + whenever(privacyNotification.canShow()).thenReturn(true) + whenever(clearNotification.canShow()).thenReturn(true) + testee.scheduleNextNotification() + + assertNotificationScheduled(PrivacyNotificationWorker::class.jvmName) + } + + @Test + fun whenInAppUsageControlVariantAndPrivacyNotificationCanShowButClearDataCannotThenPrivacyNotificationIsScheduled() = runBlocking { + setInAppUsageControlVariant() + whenever(privacyNotification.canShow()).thenReturn(true) + whenever(clearNotification.canShow()).thenReturn(false) + testee.scheduleNextNotification() + + assertNotificationScheduled(PrivacyNotificationWorker::class.jvmName) + } + + @Test + fun whenInAppUsageControlVariantAndPrivacyNotificationCannotShowAndClearNotificationCanShowThenClearNotificationScheduled() = runBlocking { + setInAppUsageControlVariant() + whenever(privacyNotification.canShow()).thenReturn(false) + whenever(clearNotification.canShow()).thenReturn(true) + testee.scheduleNextNotification() + + assertNotificationScheduled(ClearDataNotificationWorker::class.jvmName) + } + + @Test + fun whenInAppUsageControlVariantAndPrivacyNotificationAndClearNotificationCannotShowThenNoNotificationScheduled() = runBlocking { + setDefaultVariant() + whenever(privacyNotification.canShow()).thenReturn(false) + whenever(clearNotification.canShow()).thenReturn(false) + testee.scheduleNextNotification() + + assertNoNotificationScheduled() + } + + private suspend fun givenNoInactiveUserNotifications() { + whenever(privacyNotification.canShow()).thenReturn(false) + whenever(clearNotification.canShow()).thenReturn(false) + } + + private fun setInAppUsageVariant() { + whenever(variantManager.getVariant()).thenReturn( + Variant( + "test", + features = listOf( + VariantManager.VariantFeature.InAppUsage, + VariantManager.VariantFeature.RemoveDay1AndDay3Notifications, + VariantManager.VariantFeature.KillOnboarding + ), + filterBy = { true } + ) + ) + } + + private fun setInAppUsageSecondControlVariant() { + whenever(variantManager.getVariant()).thenReturn( + Variant( + "test", + features = listOf( + VariantManager.VariantFeature.RemoveDay1AndDay3Notifications, + VariantManager.VariantFeature.KillOnboarding + ), + filterBy = { true } + ) + ) + } + + private fun setInAppUsageControlVariant() { + whenever(variantManager.getVariant()).thenReturn(Variant("test", features = emptyList(), filterBy = { true })) + } + + private fun setDefaultVariant() { + whenever(variantManager.getVariant()).thenReturn(DEFAULT_VARIANT) + } + private fun assertNotificationScheduled(workerName: String, tag: String = NotificationScheduler.UNUSED_APP_WORK_REQUEST_TAG) { assertTrue(getScheduledWorkers(tag).any { it.tags.contains(workerName) }) } diff --git a/app/src/androidTest/java/com/duckduckgo/app/onboarding/store/AppUserStageStoreTest.kt b/app/src/androidTest/java/com/duckduckgo/app/onboarding/store/AppUserStageStoreTest.kt index dedb66787319..6224b3ab5585 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/onboarding/store/AppUserStageStoreTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/onboarding/store/AppUserStageStoreTest.kt @@ -17,14 +17,21 @@ package com.duckduckgo.app.onboarding.store import com.duckduckgo.app.CoroutineTestRule +import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.runBlocking +import com.duckduckgo.app.statistics.Variant +import com.duckduckgo.app.statistics.VariantManager +import com.duckduckgo.app.statistics.VariantManager.Companion.DEFAULT_VARIANT +import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.whenever import kotlinx.coroutines.ExperimentalCoroutinesApi import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test +import java.util.concurrent.TimeUnit @ExperimentalCoroutinesApi class AppUserStageStoreTest { @@ -33,8 +40,10 @@ class AppUserStageStoreTest { var coroutineRule = CoroutineTestRule() private val userStageDao: UserStageDao = mock() + private val variantManager: VariantManager = mock() + private val appInstallStore: AppInstallStore = mock() - private val testee = AppUserStageStore(userStageDao, coroutineRule.testDispatcherProvider) + private val testee = AppUserStageStore(userStageDao, coroutineRule.testDispatcherProvider, variantManager, appInstallStore) @Test fun whenGetUserAppStageThenReturnCurrentStage() = coroutineRule.runBlocking { @@ -96,6 +105,54 @@ class AppUserStageStoreTest { verify(userStageDao).updateUserStage(AppStage.USE_OUR_APP_ONBOARDING) } + @Test + fun whenAppResumedAndInstalledFor3DaysAndKillOnboardingFeatureNotActiveIfUserInOnboardingThenDoNotUpdateUserStage() = coroutineRule.runBlocking { + givenDefaultVariant() + givenCurrentStage(AppStage.DAX_ONBOARDING) + givenAppInstalledByDays(days = 3) + + testee.onAppResumed() + + verify(userStageDao, never()).updateUserStage(AppStage.ESTABLISHED) + } + + @Test + fun whenAppResumedAndInstalledFor3DaysAndKillOnboardingFeatureActiveIfUserInOnboardingThenMoveToEstablished() = coroutineRule.runBlocking { + givenKillOnboardingFeature() + givenCurrentStage(AppStage.DAX_ONBOARDING) + givenAppInstalledByDays(days = 3) + + testee.onAppResumed() + + verify(userStageDao).updateUserStage(AppStage.ESTABLISHED) + } + + @Test + fun whenAppResumedAndInstalledForLess3DaysAndKillOnboardingFeatureActiveThenDoNotUpdateUserStage() = coroutineRule.runBlocking { + givenKillOnboardingFeature() + givenCurrentStage(AppStage.DAX_ONBOARDING) + givenAppInstalledByDays(days = 2) + + testee.onAppResumed() + + verify(userStageDao, never()).updateUserStage(any()) + } + + private fun givenAppInstalledByDays(days: Long) { + whenever(appInstallStore.hasInstallTimestampRecorded()).thenReturn(true) + whenever(appInstallStore.installTimestamp).thenReturn(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days) - 1) + } + + private fun givenDefaultVariant() { + whenever(variantManager.getVariant(any())).thenReturn(DEFAULT_VARIANT) + } + + private fun givenKillOnboardingFeature() { + whenever(variantManager.getVariant()).thenReturn( + Variant("test", features = listOf(VariantManager.VariantFeature.KillOnboarding), filterBy = { true }) + ) + } + private suspend fun givenCurrentStage(appStage: AppStage) { whenever(userStageDao.currentUserAppStage()).thenReturn(UserStage(appStage = appStage)) } diff --git a/app/src/androidTest/java/com/duckduckgo/app/statistics/VariantManagerTest.kt b/app/src/androidTest/java/com/duckduckgo/app/statistics/VariantManagerTest.kt index ed5ccc69894d..40b0b0b7e68d 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/statistics/VariantManagerTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/statistics/VariantManagerTest.kt @@ -43,28 +43,31 @@ class VariantManagerTest { assertEquals(0, variant.features.size) } - // Single Search Bar Experiments + // Use our app experiment @Test - fun serpHeaderControlVariantHasExpectedWeightAndNoFeatures() { - val variant = variants.first { it.key == "zg" } - assertEqualsDouble(0.0, variant.weight) + fun inBrowserControlVariantHasExpectedWeightAndNoFeatures() { + val variant = variants.first { it.key == "ma" } + assertEqualsDouble(1.0, variant.weight) assertEquals(0, variant.features.size) } @Test - fun serpHeaderVariantHasExpectedWeightAndSERPHeaderRemovalFeature() { - val variant = variants.first { it.key == "zi" } - assertEqualsDouble(0.0, variant.weight) - assertEquals(1, variant.features.size) - assertEquals(SerpHeaderRemoval, variant.features[0]) + fun inBrowserSecondControlVariantHasExpectedWeightAndRemoveDay1And3NotificationsAndKillOnboardingFeatures() { + val variant = variants.first { it.key == "mb" } + assertEqualsDouble(1.0, variant.weight) + assertEquals(2, variant.features.size) + assertTrue(variant.hasFeature(KillOnboarding)) + assertTrue(variant.hasFeature(RemoveDay1AndDay3Notifications)) } @Test - fun serpHeaderVariantHasExpectedWeightAndSERPHeaderQueryReplacementFeature() { - val variant = variants.first { it.key == "zh" } - assertEqualsDouble(0.0, variant.weight) - assertEquals(1, variant.features.size) - assertEquals(SerpHeaderQueryReplacement, variant.features[0]) + fun inBrowserInAppUsageVariantHasExpectedWeightAndRemoveDay1And3NotificationsAndKillOnboardingAndInAppUsageFeatures() { + val variant = variants.first { it.key == "mc" } + assertEqualsDouble(1.0, variant.weight) + assertEquals(3, variant.features.size) + assertTrue(variant.hasFeature(KillOnboarding)) + assertTrue(variant.hasFeature(RemoveDay1AndDay3Notifications)) + assertTrue(variant.hasFeature(InAppUsage)) } @Test diff --git a/app/src/androidTest/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt index 4e86ab9d834a..c4d614b7d70a 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt @@ -281,7 +281,7 @@ class SystemSearchViewModelTest { override suspend fun currentUserAppStage() = UserStage(appStage = AppStage.NEW) override fun insert(userStage: UserStage) {} } - return AppUserStageStore(emptyUserStageDao, coroutineRule.testDispatcherProvider) + return AppUserStageStore(emptyUserStageDao, coroutineRule.testDispatcherProvider, mock(), mock()) } companion object { diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index 29f9840eb8f5..a53f9fd033de 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -1831,8 +1831,6 @@ class BrowserTabFragment : is DaxBubbleCta -> showDaxCta(configuration) is DialogCta -> showDaxDialogCta(configuration) } - - viewModel.onCtaShown() } private fun showDaxDialogCta(configuration: DialogCta) { @@ -1848,6 +1846,7 @@ class BrowserTabFragment : setDaxDialogListener(this@BrowserTabFragment) getDaxDialog().show(activity.supportFragmentManager, DAX_DIALOG_DIALOG_TAG) } + viewModel.onCtaShown() } } @@ -1856,6 +1855,7 @@ class BrowserTabFragment : hideHomeCta() configuration.showCta(daxCtaContainer) newTabLayout.setOnClickListener { daxCtaContainer.dialogTextCta.finishAnimation() } + viewModel.onCtaShown() } private fun removeNewTabLayoutClickListener() { @@ -1870,6 +1870,7 @@ class BrowserTabFragment : configuration.showCta(ctaContainer) } homeBackgroundLogo.showLogo() + viewModel.onCtaShown() } private fun hideDaxCta() { diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt index e7d05fd92520..0d5857506f18 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -1611,10 +1611,6 @@ class BrowserTabViewModel( ctaViewModel.onCtaShown(cta) } - fun onManualCtaShown(cta: Cta) { - ctaViewModel.onCtaShown(cta) - } - suspend fun refreshCta(locale: Locale = Locale.getDefault()): Cta? { if (currentGlobalLayoutState() is Browser) { val cta = withContext(dispatchers.io()) { diff --git a/app/src/main/java/com/duckduckgo/app/browser/shortcut/ShortcutReceiver.kt b/app/src/main/java/com/duckduckgo/app/browser/shortcut/ShortcutReceiver.kt index 28b90c6e5e05..f5acdc705cbc 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/shortcut/ShortcutReceiver.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/shortcut/ShortcutReceiver.kt @@ -24,14 +24,23 @@ import android.widget.Toast import com.duckduckgo.app.browser.R import com.duckduckgo.app.browser.shortcut.ShortcutBuilder.Companion.SHORTCUT_TITLE_ARG import com.duckduckgo.app.browser.shortcut.ShortcutBuilder.Companion.SHORTCUT_URL_ARG +import com.duckduckgo.app.global.DispatcherProvider +import com.duckduckgo.app.global.events.db.UserEventKey +import com.duckduckgo.app.global.events.db.UserEventsStore import com.duckduckgo.app.global.useourapp.UseOurAppDetector import com.duckduckgo.app.pixels.AppPixelName +import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.pixels.Pixel +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import javax.inject.Inject class ShortcutReceiver @Inject constructor( private val useOurAppDetector: UseOurAppDetector, - private val pixel: Pixel + private val pixel: Pixel, + private val userEventsStore: UserEventsStore, + private val dispatcher: DispatcherProvider, + private val variantManager: VariantManager ) : BroadcastReceiver() { @@ -45,10 +54,15 @@ class ShortcutReceiver @Inject constructor( } } - if (useOurAppDetector.isUseOurAppUrl(originUrl)) { - pixel.fire(AppPixelName.USE_OUR_APP_SHORTCUT_ADDED) - } else { - pixel.fire(AppPixelName.SHORTCUT_ADDED) + GlobalScope.launch(dispatcher.io()) { + if (useOurAppDetector.isUseOurAppUrl(originUrl)) { + pixel.fire(AppPixelName.USE_OUR_APP_SHORTCUT_ADDED) + if (variantManager.getVariant().hasFeature(VariantManager.VariantFeature.InAppUsage)) { + userEventsStore.registerUserEvent(UserEventKey.USE_OUR_APP_SHORTCUT_ADDED) + } + } else { + pixel.fire(AppPixelName.SHORTCUT_ADDED) + } } } diff --git a/app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt b/app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt index 0f507da049ac..015065d93b45 100644 --- a/app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt +++ b/app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt @@ -73,7 +73,7 @@ class UseOurAppCta( @StringRes val okButton: Int = R.string.useOurAppDialogButtonText, @StringRes val cancelButton: Int = R.string.useOurAppDialogCancelButtonText, override val ctaId: CtaId = CtaId.USE_OUR_APP, - override val shownPixel: Pixel.PixelName? = AppPixelName.USE_OUR_APP_DIALOG_SHOWN, + override val shownPixel: Pixel.PixelName? = null, override val okPixel: Pixel.PixelName? = AppPixelName.USE_OUR_APP_DIALOG_OK, override val cancelPixel: Pixel.PixelName? = null ) : Cta, DialogCta { diff --git a/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt b/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt index 4382620674b2..3031533900e7 100644 --- a/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt @@ -225,7 +225,9 @@ class CtaViewModel @Inject constructor( canShowDaxDialogCta() -> { getDaxDialogCta(site) } - canShowUseOurAppDeletionDialog(site) -> UseOurAppDeletionCta() + canShowUseOurAppDeletionDialog(site) -> { + UseOurAppDeletionCta() + } else -> null } } diff --git a/app/src/main/java/com/duckduckgo/app/di/NotificationModule.kt b/app/src/main/java/com/duckduckgo/app/di/NotificationModule.kt index ed83bc0a8c03..fb40b498e744 100644 --- a/app/src/main/java/com/duckduckgo/app/di/NotificationModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/NotificationModule.kt @@ -31,6 +31,7 @@ import com.duckduckgo.app.notification.model.UseOurAppNotification import com.duckduckgo.app.notification.model.PrivacyProtectionNotification import com.duckduckgo.app.privacy.db.PrivacyProtectionCountDao import com.duckduckgo.app.settings.db.SettingsDataStore +import com.duckduckgo.app.statistics.VariantManager import dagger.Module import dagger.Provides import javax.inject.Singleton @@ -89,12 +90,16 @@ class NotificationModule { fun providesNotificationScheduler( workManager: WorkManager, clearDataNotification: ClearDataNotification, - privacyProtectionNotification: PrivacyProtectionNotification + privacyProtectionNotification: PrivacyProtectionNotification, + useOurAppNotification: UseOurAppNotification, + variantManager: VariantManager ): AndroidNotificationScheduler { return NotificationScheduler( workManager, clearDataNotification, - privacyProtectionNotification + privacyProtectionNotification, + useOurAppNotification, + variantManager ) } diff --git a/app/src/main/java/com/duckduckgo/app/job/JobCleaner.kt b/app/src/main/java/com/duckduckgo/app/job/JobCleaner.kt index 7b317773d7a5..7597fc9547c8 100644 --- a/app/src/main/java/com/duckduckgo/app/job/JobCleaner.kt +++ b/app/src/main/java/com/duckduckgo/app/job/JobCleaner.kt @@ -24,9 +24,8 @@ interface JobCleaner { companion object { private const val STICKY_SEARCH_CONTINUOUS_APP_USE_REQUEST_TAG = "com.duckduckgo.notification.schedule.continuous" - private const val USE_OUR_APP_WORK_REQUEST_TAG = "com.duckduckgo.notification.useOurApp" - fun allDeprecatedNotificationWorkTags() = listOf(STICKY_SEARCH_CONTINUOUS_APP_USE_REQUEST_TAG, USE_OUR_APP_WORK_REQUEST_TAG) + fun allDeprecatedNotificationWorkTags() = listOf(STICKY_SEARCH_CONTINUOUS_APP_USE_REQUEST_TAG) } } diff --git a/app/src/main/java/com/duckduckgo/app/notification/AndroidNotificationScheduler.kt b/app/src/main/java/com/duckduckgo/app/notification/AndroidNotificationScheduler.kt index 528355a4ba5e..302d73a9fad1 100644 --- a/app/src/main/java/com/duckduckgo/app/notification/AndroidNotificationScheduler.kt +++ b/app/src/main/java/com/duckduckgo/app/notification/AndroidNotificationScheduler.kt @@ -26,10 +26,12 @@ import com.duckduckgo.app.notification.model.ClearDataNotification import com.duckduckgo.app.notification.model.Notification import com.duckduckgo.app.notification.model.PrivacyProtectionNotification import com.duckduckgo.app.notification.model.SchedulableNotification +import com.duckduckgo.app.notification.model.UseOurAppNotification import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.pixels.AppPixelName.NOTIFICATION_SHOWN import com.duckduckgo.di.scopes.AppObjectGraph import com.squareup.anvil.annotations.ContributesMultibinding +import com.duckduckgo.app.statistics.VariantManager import timber.log.Timber import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -44,27 +46,58 @@ interface AndroidNotificationScheduler { class NotificationScheduler( private val workManager: WorkManager, private val clearDataNotification: SchedulableNotification, - private val privacyNotification: SchedulableNotification + private val privacyNotification: SchedulableNotification, + private val useOurAppNotification: SchedulableNotification, + private val variantManager: VariantManager ) : AndroidNotificationScheduler { override suspend fun scheduleNextNotification() { + scheduleUseOurAppNotification() scheduleInactiveUserNotifications() } + private suspend fun scheduleUseOurAppNotification() { + if (variant().hasFeature(VariantManager.VariantFeature.InAppUsage) && useOurAppNotification.canShow()) { + val operation = scheduleUniqueNotification( + OneTimeWorkRequestBuilder(), + 3, + TimeUnit.DAYS, + USE_OUR_APP_WORK_REQUEST_TAG + ) + try { + operation.await() + } catch (e: Exception) { + Timber.v("Notification could not be scheduled: $e") + } + } + } + private suspend fun scheduleInactiveUserNotifications() { workManager.cancelAllWorkByTag(UNUSED_APP_WORK_REQUEST_TAG) when { - privacyNotification.canShow() -> { + (!variant().hasFeature(VariantManager.VariantFeature.RemoveDay1AndDay3Notifications) && privacyNotification.canShow()) -> { scheduleNotification(OneTimeWorkRequestBuilder(), 1, TimeUnit.DAYS, UNUSED_APP_WORK_REQUEST_TAG) } - clearDataNotification.canShow() -> { + (!variant().hasFeature(VariantManager.VariantFeature.RemoveDay1AndDay3Notifications) && clearDataNotification.canShow()) -> { scheduleNotification(OneTimeWorkRequestBuilder(), 3, TimeUnit.DAYS, UNUSED_APP_WORK_REQUEST_TAG) } else -> Timber.v("Notifications not enabled for this variant") } } + private fun variant() = variantManager.getVariant() + + private fun scheduleUniqueNotification(builder: OneTimeWorkRequest.Builder, duration: Long, unit: TimeUnit, tag: String): Operation { + Timber.v("Scheduling unique notification") + val request = builder + .addTag(tag) + .setInitialDelay(duration, unit) + .build() + + return workManager.enqueueUniqueWork(tag, ExistingWorkPolicy.KEEP, request) + } + private fun scheduleNotification(builder: OneTimeWorkRequest.Builder, duration: Long, unit: TimeUnit, tag: String) { Timber.v("Scheduling notification") val request = builder @@ -81,6 +114,7 @@ class NotificationScheduler( open class ClearDataNotificationWorker(context: Context, params: WorkerParameters) : SchedulableNotificationWorker(context, params) class PrivacyNotificationWorker(context: Context, params: WorkerParameters) : SchedulableNotificationWorker(context, params) + class UseOurAppNotificationWorker(context: Context, params: WorkerParameters) : SchedulableNotificationWorker(context, params) open class SchedulableNotificationWorker(val context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { @@ -111,6 +145,7 @@ class NotificationScheduler( companion object { const val UNUSED_APP_WORK_REQUEST_TAG = "com.duckduckgo.notification.schedule" + const val USE_OUR_APP_WORK_REQUEST_TAG = "com.duckduckgo.notification.useOurApp" } } @@ -157,3 +192,24 @@ class PrivacyNotificationWorkerInjectorPlugin @Inject constructor( return false } } +@ContributesMultibinding(AppObjectGraph::class) +class UseOurAppNotificationWorkerInjectorPlugin @Inject constructor( + private val notificationManagerCompat: NotificationManagerCompat, + private val notificationDao: NotificationDao, + private val notificationFactory: NotificationFactory, + private val pixel: Pixel, + private val useOurAppNotification: UseOurAppNotification +) : WorkerInjectorPlugin { + + override fun inject(worker: ListenableWorker): Boolean { + if (worker is NotificationScheduler.UseOurAppNotificationWorker) { + worker.manager = notificationManagerCompat + worker.notificationDao = notificationDao + worker.factory = notificationFactory + worker.pixel = pixel + worker.notification = useOurAppNotification + return true + } + return false + } +} diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/store/UserStageStore.kt b/app/src/main/java/com/duckduckgo/app/onboarding/store/UserStageStore.kt index 778f5d62e506..696ca8142e03 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/store/UserStageStore.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/store/UserStageStore.kt @@ -16,9 +16,16 @@ package com.duckduckgo.app.onboarding.store +import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.OnLifecycleEvent import com.duckduckgo.app.global.DispatcherProvider +import com.duckduckgo.app.global.install.AppInstallStore +import com.duckduckgo.app.statistics.VariantManager +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.concurrent.TimeUnit import javax.inject.Inject interface UserStageStore : LifecycleObserver { @@ -27,7 +34,19 @@ interface UserStageStore : LifecycleObserver { suspend fun moveToStage(appStage: AppStage) } -class AppUserStageStore @Inject constructor(private val userStageDao: UserStageDao, private val dispatcher: DispatcherProvider) : UserStageStore { +class AppUserStageStore @Inject constructor( + private val userStageDao: UserStageDao, + private val dispatcher: DispatcherProvider, + private val variantManager: VariantManager, + private val appInstallStore: AppInstallStore +) : UserStageStore, LifecycleObserver { + + @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) + fun onAppResumed() { + GlobalScope.launch(dispatcher.io()) { + moveUserToEstablished3DaysAfterInstall() + } + } override suspend fun getUserAppStage(): AppStage { return withContext(dispatcher.io()) { @@ -57,6 +76,17 @@ class AppUserStageStore @Inject constructor(private val userStageDao: UserStageD override suspend fun moveToStage(appStage: AppStage) { userStageDao.updateUserStage(appStage) } + + private suspend fun moveUserToEstablished3DaysAfterInstall() { + if (variantManager.getVariant().hasFeature(VariantManager.VariantFeature.KillOnboarding)) { + if (appInstallStore.hasInstallTimestampRecorded() && daxOnboardingActive()) { + val days = TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis() - appInstallStore.installTimestamp) + if (days >= 3) { + moveToStage(AppStage.ESTABLISHED) + } + } + } + } } suspend fun UserStageStore.isNewUser(): Boolean { diff --git a/statistics/src/main/java/com/duckduckgo/app/statistics/VariantManager.kt b/statistics/src/main/java/com/duckduckgo/app/statistics/VariantManager.kt index de82d1df63f0..8d2a8f0a019b 100644 --- a/statistics/src/main/java/com/duckduckgo/app/statistics/VariantManager.kt +++ b/statistics/src/main/java/com/duckduckgo/app/statistics/VariantManager.kt @@ -28,8 +28,9 @@ interface VariantManager { // variant-dependant features listed here sealed class VariantFeature { - object SerpHeaderQueryReplacement : VariantFeature() - object SerpHeaderRemoval : VariantFeature() + object InAppUsage : VariantFeature() + object KillOnboarding : VariantFeature() + object RemoveDay1AndDay3Notifications : VariantFeature() } companion object { @@ -45,10 +46,10 @@ interface VariantManager { Variant(key = "sc", weight = 0.0, features = emptyList(), filterBy = { isSerpRegionToggleCountry() }), Variant(key = "se", weight = 0.0, features = emptyList(), filterBy = { isSerpRegionToggleCountry() }), - // Single Search Bar Experiments - Variant(key = "zg", weight = 0.0, features = emptyList(), filterBy = { noFilter() }), - Variant(key = "zh", weight = 0.0, features = listOf(VariantFeature.SerpHeaderQueryReplacement), filterBy = { noFilter() }), - Variant(key = "zi", weight = 0.0, features = listOf(VariantFeature.SerpHeaderRemoval), filterBy = { noFilter() }) + // InAppUsage Experiments + Variant(key = "ma", weight = 1.0, features = emptyList(), filterBy = { isEnglishLocale() }), + Variant(key = "mb", weight = 1.0, features = listOf(VariantFeature.KillOnboarding, VariantFeature.RemoveDay1AndDay3Notifications), filterBy = { isEnglishLocale() }), + Variant(key = "mc", weight = 1.0, features = listOf(VariantFeature.KillOnboarding, VariantFeature.InAppUsage, VariantFeature.RemoveDay1AndDay3Notifications), filterBy = { isEnglishLocale() }) ) val REFERRER_VARIANTS = listOf(