diff --git a/app-build-config/app-build-config-api/src/main/java/com/duckduckgo/appbuildconfig/api/AppBuildConfig.kt b/app-build-config/app-build-config-api/src/main/java/com/duckduckgo/appbuildconfig/api/AppBuildConfig.kt index 4d6f63edf828..b7ac9272df66 100644 --- a/app-build-config/app-build-config-api/src/main/java/com/duckduckgo/appbuildconfig/api/AppBuildConfig.kt +++ b/app-build-config/app-build-config-api/src/main/java/com/duckduckgo/appbuildconfig/api/AppBuildConfig.kt @@ -38,6 +38,11 @@ interface AppBuildConfig { * You should call [variantName] in a background thread */ val variantName: String? + + /** + * @return `true` if the user re-installed the app, `false` otherwise + */ + suspend fun isAppReinstall(): Boolean } enum class BuildFlavor { diff --git a/app/src/androidTest/java/com/duckduckgo/app/di/StubStatisticsModule.kt b/app/src/androidTest/java/com/duckduckgo/app/di/StubStatisticsModule.kt index e86800fba459..fd5ff2405b0f 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/di/StubStatisticsModule.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/di/StubStatisticsModule.kt @@ -31,7 +31,7 @@ import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.device.ContextDeviceInfo import com.duckduckgo.common.utils.device.DeviceInfo -import com.duckduckgo.di.DaggerSet +import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesTo import dagger.Module @@ -115,7 +115,7 @@ class StubStatisticsModule { @AppCoroutineScope appCoroutineScope: CoroutineScope, statisticsDataStore: StatisticsDataStore, statisticsUpdater: StatisticsUpdater, - listeners: DaggerSet, + listeners: PluginPoint, dispatcherProvider: DispatcherProvider, ): MainProcessLifecycleObserver { return AtbInitializer(appCoroutineScope, statisticsDataStore, statisticsUpdater, listeners, dispatcherProvider) diff --git a/app/src/main/java/com/duckduckgo/app/buildconfig/RealAppBuildConfig.kt b/app/src/main/java/com/duckduckgo/app/buildconfig/RealAppBuildConfig.kt index 9c95226d5bb5..d588b63c6858 100644 --- a/app/src/main/java/com/duckduckgo/app/buildconfig/RealAppBuildConfig.kt +++ b/app/src/main/java/com/duckduckgo/app/buildconfig/RealAppBuildConfig.kt @@ -17,21 +17,34 @@ package com.duckduckgo.app.buildconfig import android.os.Build +import android.os.Environment +import androidx.core.content.edit import com.duckduckgo.app.browser.BuildConfig import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.appbuildconfig.api.BuildFlavor +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.data.store.api.SharedPreferencesProvider import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.experiments.api.VariantManager import com.squareup.anvil.annotations.ContributesBinding import dagger.Lazy +import java.io.File import java.lang.IllegalStateException import java.util.* import javax.inject.Inject +import kotlinx.coroutines.withContext +import timber.log.Timber @ContributesBinding(AppScope::class) class RealAppBuildConfig @Inject constructor( private val variantManager: Lazy, // break any possible DI dependency cycle + private val dispatcherProvider: DispatcherProvider, + private val sharedPreferencesProvider: SharedPreferencesProvider, ) : AppBuildConfig { + private val preferences by lazy { + sharedPreferencesProvider.getSharedPreferences("com.duckduckgo.app.buildconfig.cache", false, false) + } + override val isDebug: Boolean = BuildConfig.DEBUG override val applicationId: String = BuildConfig.APPLICATION_ID override val buildType: String = BuildConfig.BUILD_TYPE @@ -65,6 +78,59 @@ class RealAppBuildConfig @Inject constructor( override val variantName: String? get() = variantManager.get().getVariantKey() + override suspend fun isAppReinstall(): Boolean = withContext(dispatcherProvider.io()) { + return@withContext kotlin.runCatching { + if (sdkInt < 30) { + return@withContext false + } + + if (preferences.contains(APP_REINSTALLED_KEY)) { + return@withContext preferences.getBoolean(APP_REINSTALLED_KEY, false) + } + + val downloadDirectory = getDownloadsDirectory() + val ddgDirectoryExists = (downloadDirectory.list()?.asList() ?: emptyList()).contains(DDG_DOWNLOADS_DIRECTORY) + val appReinstallValue = if (!ddgDirectoryExists) { + createNewDirectory(DDG_DOWNLOADS_DIRECTORY) + // this is a new install + false + } else { + true + } + preferences.edit(commit = true) { putBoolean(APP_REINSTALLED_KEY, appReinstallValue) } + return@withContext appReinstallValue + }.getOrDefault(false) + } + override val buildDateTimeMillis: Long get() = BuildConfig.BUILD_DATE_MILLIS + + private fun getDownloadsDirectory(): File { + val downloadDirectory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + if (!downloadDirectory.exists()) { + Timber.i("Download directory doesn't exist; trying to create it. %s", downloadDirectory.absolutePath) + downloadDirectory.mkdirs() + } + return downloadDirectory + } + + private fun createNewDirectory(directoryName: String) { + val directory = File(getDownloadsDirectory(), directoryName) + val success = directory.mkdirs() + Timber.i("Directory creation success: %s", success) + if (!success) { + Timber.e("Directory creation failed") + kotlin.runCatching { + val directoryCreationSuccess = directory.createNewFile() + Timber.i("File creation success: %s", directoryCreationSuccess) + }.onFailure { + Timber.w("Failed to create file: %s", it.message) + } + } + } + + companion object { + private const val APP_REINSTALLED_KEY = "appReinstalled" + private const val DDG_DOWNLOADS_DIRECTORY = "DuckDuckGo" + } } diff --git a/app/src/main/java/com/duckduckgo/app/pixels/campaign/params/StatisticsAdditionalPixelParams.kt b/app/src/main/java/com/duckduckgo/app/pixels/campaign/params/StatisticsAdditionalPixelParams.kt index 00102805b37f..fe042e20bef0 100644 --- a/app/src/main/java/com/duckduckgo/app/pixels/campaign/params/StatisticsAdditionalPixelParams.kt +++ b/app/src/main/java/com/duckduckgo/app/pixels/campaign/params/StatisticsAdditionalPixelParams.kt @@ -16,17 +16,17 @@ package com.duckduckgo.app.pixels.campaign.params -import com.duckduckgo.app.statistics.store.StatisticsDataStore +import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesMultibinding import javax.inject.Inject @ContributesMultibinding(AppScope::class) class ReinstallAdditionalPixelParamPlugin @Inject constructor( - private val statisticsDataStore: StatisticsDataStore, + private val appBuildConfig: AppBuildConfig, ) : AdditionalPixelParamPlugin { override suspend fun params(): Pair = Pair( "isReinstall", - "${statisticsDataStore.variant == "ru"}", + "${appBuildConfig.isAppReinstall()}", ) } diff --git a/app/src/main/java/com/duckduckgo/app/referral/AppReferrerDataStore.kt b/app/src/main/java/com/duckduckgo/app/referral/AppReferrerDataStore.kt index 9c4f3873da8d..5810c911f75b 100644 --- a/app/src/main/java/com/duckduckgo/app/referral/AppReferrerDataStore.kt +++ b/app/src/main/java/com/duckduckgo/app/referral/AppReferrerDataStore.kt @@ -19,10 +19,15 @@ package com.duckduckgo.app.referral import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.browser.api.referrer.AppReferrer +import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesBinding import dagger.SingleInstanceIn import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch interface AppReferrerDataStore { var referrerCheckedPreviously: Boolean @@ -31,9 +36,27 @@ interface AppReferrerDataStore { var utmOriginAttributeCampaign: String? } -@ContributesBinding(AppScope::class) +@ContributesBinding( + scope = AppScope::class, + boundType = AppReferrerDataStore::class, +) +@ContributesBinding( + scope = AppScope::class, + boundType = AppReferrer::class, +) @SingleInstanceIn(AppScope::class) -class AppReferenceSharePreferences @Inject constructor(private val context: Context) : AppReferrerDataStore { +class AppReferenceSharePreferences @Inject constructor( + private val context: Context, + @AppCoroutineScope private val coroutineScope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, +) : AppReferrerDataStore, AppReferrer { + + override fun setOriginAttributeCampaign(origin: String?) { + coroutineScope.launch(dispatcherProvider.io()) { + utmOriginAttributeCampaign = origin + } + } + override var campaignSuffix: String? get() = preferences.getString(KEY_CAMPAIGN_SUFFIX, null) set(value) = preferences.edit(true) { putString(KEY_CAMPAIGN_SUFFIX, value) } diff --git a/app/src/play/java/com/duckduckgo/referral/AppReferrerInstallPixelSender.kt b/app/src/play/java/com/duckduckgo/referral/AppReferrerInstallPixelSender.kt index 2f8604593a0f..0a5da439feab 100644 --- a/app/src/play/java/com/duckduckgo/referral/AppReferrerInstallPixelSender.kt +++ b/app/src/play/java/com/duckduckgo/referral/AppReferrerInstallPixelSender.kt @@ -21,7 +21,6 @@ import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.referral.AppReferrerDataStore import com.duckduckgo.app.statistics.api.AtbLifecyclePlugin import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope @@ -39,7 +38,6 @@ class AppReferrerInstallPixelSender @Inject constructor( @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val dispatchers: DispatcherProvider, private val appBuildConfig: AppBuildConfig, - private val statisticsDataStore: StatisticsDataStore, ) : AtbLifecyclePlugin { private val pixelSent = AtomicBoolean(false) @@ -57,8 +55,8 @@ class AppReferrerInstallPixelSender @Inject constructor( } } - private fun sendOriginAttribute(originAttribute: String?) { - val returningUser = statisticsDataStore.variant == RETURNING_USER_VARIANT + private suspend fun sendOriginAttribute(originAttribute: String?) { + val returningUser = appBuildConfig.isAppReinstall() val params = mutableMapOf( PIXEL_PARAM_LOCALE to appBuildConfig.deviceLocale.toLanguageTag(), @@ -74,8 +72,6 @@ class AppReferrerInstallPixelSender @Inject constructor( } companion object { - private const val RETURNING_USER_VARIANT = "ru" - const val PIXEL_PARAM_ORIGIN = "origin" const val PIXEL_PARAM_LOCALE = "locale" const val PIXEL_PARAM_RETURNING_USER = "reinstall" diff --git a/app/src/test/java/com/duckduckgo/app/pixels/campaign/params/StatisticsAdditionalPixelParamPluginTest.kt b/app/src/test/java/com/duckduckgo/app/pixels/campaign/params/StatisticsAdditionalPixelParamPluginTest.kt index 401dfc2cd61d..9b2d59879feb 100644 --- a/app/src/test/java/com/duckduckgo/app/pixels/campaign/params/StatisticsAdditionalPixelParamPluginTest.kt +++ b/app/src/test/java/com/duckduckgo/app/pixels/campaign/params/StatisticsAdditionalPixelParamPluginTest.kt @@ -16,7 +16,7 @@ package com.duckduckgo.app.pixels.campaign.params -import com.duckduckgo.app.statistics.store.StatisticsDataStore +import com.duckduckgo.appbuildconfig.api.AppBuildConfig import kotlinx.coroutines.test.runTest import org.junit.Assert import org.junit.Test @@ -24,20 +24,20 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.whenever class StatisticsAdditionalPixelParamPluginTest { + private val appBuildConfig: AppBuildConfig = mock() + @Test fun whenRuVariantSetThenPluginShouldReturnParamTrue() = runTest { - val statisticsDataStore: StatisticsDataStore = mock() - whenever(statisticsDataStore.variant).thenReturn("ru") - val plugin = ReinstallAdditionalPixelParamPlugin(statisticsDataStore) + whenever(appBuildConfig.isAppReinstall()).thenReturn(true) + val plugin = ReinstallAdditionalPixelParamPlugin(appBuildConfig) Assert.assertEquals("isReinstall" to "true", plugin.params()) } @Test fun whenVariantIsNotRuThenPluginShouldReturnParamFalse() = runTest { - val statisticsDataStore: StatisticsDataStore = mock() - whenever(statisticsDataStore.variant).thenReturn("atb-1234") - val plugin = ReinstallAdditionalPixelParamPlugin(statisticsDataStore) + whenever(appBuildConfig.isAppReinstall()).thenReturn(false) + val plugin = ReinstallAdditionalPixelParamPlugin(appBuildConfig) Assert.assertEquals("isReinstall" to "false", plugin.params()) } diff --git a/app/src/testPlay/java/com/duckduckgo/app/referrer/AppReferrerInstallPixelSenderTest.kt b/app/src/testPlay/java/com/duckduckgo/app/referrer/AppReferrerInstallPixelSenderTest.kt index 6746f2eb13bd..d6ab133a47af 100644 --- a/app/src/testPlay/java/com/duckduckgo/app/referrer/AppReferrerInstallPixelSenderTest.kt +++ b/app/src/testPlay/java/com/duckduckgo/app/referrer/AppReferrerInstallPixelSenderTest.kt @@ -4,7 +4,6 @@ import com.duckduckgo.app.pixels.AppPixelName.REFERRAL_INSTALL_UTM_CAMPAIGN import com.duckduckgo.app.referral.AppReferrerDataStore import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique -import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.referral.AppReferrerInstallPixelSender @@ -35,14 +34,14 @@ class AppReferrerInstallPixelSenderTest { private val pixel: Pixel = mock() private val appBuildConfig: AppBuildConfig = mock() private val appReferrerDataStore: AppReferrerDataStore = mock() - private val statisticsDataStore: StatisticsDataStore = mock() private val playStoreInstallChecker: VerificationCheckPlayStoreInstall = mock() private val captor = argumentCaptor>() @Before - fun setup() { + fun setup() = runTest { whenever(appBuildConfig.deviceLocale).thenReturn(Locale.US) whenever(playStoreInstallChecker.installedFromPlayStore()).thenReturn(true) + configureAsNewUser() } private val testee = AppReferrerInstallPixelSender( @@ -51,7 +50,6 @@ class AppReferrerInstallPixelSenderTest { appCoroutineScope = coroutineTestRule.testScope, dispatchers = coroutineTestRule.testDispatcherProvider, appBuildConfig = appBuildConfig, - statisticsDataStore = statisticsDataStore, ) @Test @@ -85,12 +83,12 @@ class AppReferrerInstallPixelSenderTest { verifyNoMoreInteractions(pixel) } - private fun configureAsReturningUser() { - whenever(statisticsDataStore.variant).thenReturn("ru") + private suspend fun configureAsReturningUser() { + whenever(appBuildConfig.isAppReinstall()).thenReturn(true) } - private fun configureAsNewUser() { - whenever(statisticsDataStore.variant).thenReturn("") + private suspend fun configureAsNewUser() { + whenever(appBuildConfig.isAppReinstall()).thenReturn(false) } private fun configureReferrerCampaign(campaign: String?) { diff --git a/browser-api/src/main/java/com/duckduckgo/browser/api/referrer/AppReferrer.kt b/browser-api/src/main/java/com/duckduckgo/browser/api/referrer/AppReferrer.kt new file mode 100644 index 000000000000..5af1d10b24fb --- /dev/null +++ b/browser-api/src/main/java/com/duckduckgo/browser/api/referrer/AppReferrer.kt @@ -0,0 +1,26 @@ +/* + * 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.browser.api.referrer + +/** Public interface for app referral parameters */ +interface AppReferrer { + + /** + * Sets the attribute campaign origin. + */ + fun setOriginAttributeCampaign(origin: String?) +} diff --git a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/reinstalls/DownloadsDirectoryManager.kt b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/reinstalls/DownloadsDirectoryManager.kt deleted file mode 100644 index 158051de1d23..000000000000 --- a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/reinstalls/DownloadsDirectoryManager.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * 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.experiments.impl.reinstalls - -import android.os.Environment -import com.duckduckgo.di.scopes.AppScope -import com.squareup.anvil.annotations.ContributesBinding -import java.io.File -import javax.inject.Inject -import timber.log.Timber - -interface DownloadsDirectoryManager { - - fun getDownloadsDirectory(): File - fun createNewDirectory(directoryName: String) -} - -@ContributesBinding(AppScope::class) -class DownloadsDirectoryManagerImpl @Inject constructor() : DownloadsDirectoryManager { - - override fun getDownloadsDirectory(): File { - val downloadDirectory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) - if (!downloadDirectory.exists()) { - Timber.i("Download directory doesn't exist; trying to create it. %s", downloadDirectory.absolutePath) - downloadDirectory.mkdirs() - } - return downloadDirectory - } - - override fun createNewDirectory(directoryName: String) { - val directory = File(getDownloadsDirectory(), directoryName) - val success = directory.mkdirs() - Timber.i("Directory creation success: %s", success) - if (!success) { - Timber.e("Directory creation failed") - kotlin.runCatching { - val directoryCreationSuccess = directory.createNewFile() - Timber.i("File creation success: %s", directoryCreationSuccess) - }.onFailure { - Timber.w("Failed to create file: %s", it.message) - } - } - } -} diff --git a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/reinstalls/ReinstallAtbListener.kt b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/reinstalls/ReinstallAtbListener.kt index 508b6f9c203a..0491692f36a3 100644 --- a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/reinstalls/ReinstallAtbListener.kt +++ b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/reinstalls/ReinstallAtbListener.kt @@ -16,85 +16,42 @@ package com.duckduckgo.experiments.impl.reinstalls -import android.content.Context -import android.content.SharedPreferences -import android.os.Build -import androidx.core.content.edit +import com.duckduckgo.anvil.annotations.PriorityKey import com.duckduckgo.app.statistics.AtbInitializerListener import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesMultibinding -import com.squareup.anvil.annotations.ContributesTo -import dagger.Lazy -import dagger.Module -import dagger.Provides import dagger.SingleInstanceIn import javax.inject.Inject -import javax.inject.Qualifier import kotlinx.coroutines.withContext import timber.log.Timber @SingleInstanceIn(AppScope::class) @ContributesMultibinding(AppScope::class) +@PriorityKey(AtbInitializerListener.PRIORITY_REINSTALL_LISTENER) class ReinstallAtbListener @Inject constructor( private val backupDataStore: BackupServiceDataStore, private val statisticsDataStore: StatisticsDataStore, private val appBuildConfig: AppBuildConfig, - private val downloadsDirectoryManager: DownloadsDirectoryManager, - @ReinstallSharedPrefs private val reinstallSharedPrefs: Lazy, private val dispatcherProvider: DispatcherProvider, ) : AtbInitializerListener { override suspend fun beforeAtbInit() = withContext(dispatcherProvider.io()) { backupDataStore.clearBackupPreferences() - if (appBuildConfig.sdkInt >= Build.VERSION_CODES.R && !reinstallSharedPrefs.get().isReturningUserChecked()) { - val downloadDirectory = downloadsDirectoryManager.getDownloadsDirectory() - - val downloadFiles = downloadDirectory.list()?.asList() ?: emptyList() - val ddgDirectoryExists = downloadFiles.contains(DDG_DOWNLOADS_DIRECTORY) - if (ddgDirectoryExists) { - statisticsDataStore.variant = REINSTALL_VARIANT - Timber.i("Variant update for returning user") - } else { - downloadsDirectoryManager.createNewDirectory(DDG_DOWNLOADS_DIRECTORY) - } - reinstallSharedPrefs.get().setReturningUserChecked() + if (appBuildConfig.isAppReinstall()) { + statisticsDataStore.variant = REINSTALL_VARIANT + Timber.i("Variant update for returning user") } } override fun beforeAtbInitTimeoutMillis(): Long = MAX_REINSTALL_WAIT_TIME_MS - private fun SharedPreferences.isReturningUserChecked(): Boolean { - return getBoolean(RETURNING_USER_CHECKED_TAG, false) - } - - private fun SharedPreferences.setReturningUserChecked() { - this.edit(commit = true) { putBoolean(RETURNING_USER_CHECKED_TAG, true) } - } - companion object { private const val MAX_REINSTALL_WAIT_TIME_MS = 1_500L - private const val DDG_DOWNLOADS_DIRECTORY = "DuckDuckGo" - private const val RETURNING_USER_CHECKED_TAG = "RETURNING_USER_CHECKED_TAG" } } internal const val REINSTALL_VARIANT = "ru" - -@Retention(AnnotationRetention.BINARY) -@Qualifier -private annotation class ReinstallSharedPrefs - -@Module -@ContributesTo(AppScope::class) -class ReinstallAtbListenerModule { - @Provides - @ReinstallSharedPrefs - fun provideReinstallSharedPrefs(context: Context): SharedPreferences { - val filename = "com.duckduckgo.experiments.impl.reinstalls.store.v1" - return context.getSharedPreferences(filename, Context.MODE_PRIVATE) - } -} diff --git a/experiments/experiments-impl/src/test/java/com/duckduckgo/experiments/impl/reinstalls/ReinstallAtbListenerTest.kt b/experiments/experiments-impl/src/test/java/com/duckduckgo/experiments/impl/reinstalls/ReinstallAtbListenerTest.kt index 18dd3ecdbb8a..3a3e1e449fcc 100644 --- a/experiments/experiments-impl/src/test/java/com/duckduckgo/experiments/impl/reinstalls/ReinstallAtbListenerTest.kt +++ b/experiments/experiments-impl/src/test/java/com/duckduckgo/experiments/impl/reinstalls/ReinstallAtbListenerTest.kt @@ -16,19 +16,13 @@ package com.duckduckgo.experiments.impl.reinstalls -import android.os.Build -import androidx.core.content.edit import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.test.CoroutineTestRule -import com.duckduckgo.common.test.api.InMemorySharedPreferences -import java.io.File import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test -import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -41,8 +35,6 @@ class ReinstallAtbListenerTest { private val mockBackupDataStore: BackupServiceDataStore = mock() private val mockStatisticsDataStore: StatisticsDataStore = mock() private val mockAppBuildConfig: AppBuildConfig = mock() - private val mockDownloadsDirectoryManager: DownloadsDirectoryManager = mock() - private val preferences = InMemorySharedPreferences() @get:Rule val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() @@ -53,85 +45,34 @@ class ReinstallAtbListenerTest { mockBackupDataStore, mockStatisticsDataStore, mockAppBuildConfig, - mockDownloadsDirectoryManager, - { preferences }, coroutineTestRule.testDispatcherProvider, ) } @Test fun whenBeforeAtbInitIsCalledThenClearBackupServiceSharedPreferences() = runTest { - testee.beforeAtbInit() - - verify(mockBackupDataStore).clearBackupPreferences() - } - - @Test - fun whenAndroidVersionIs10OrLowerThenDontCheckForDownloadsDirectory() = runTest { - whenever(mockAppBuildConfig.sdkInt).thenReturn(Build.VERSION_CODES.Q) - - testee.beforeAtbInit() - - verify(mockDownloadsDirectoryManager, never()).getDownloadsDirectory() - } - - @Test - fun whenReturningUserHasBeenAlreadyCheckedThenDontCheckForDownloadsDirectory() = runTest { - whenever(mockAppBuildConfig.sdkInt).thenReturn(Build.VERSION_CODES.R) - setReturningUserChecked() + whenever(mockAppBuildConfig.isAppReinstall()).thenReturn(false) testee.beforeAtbInit() - verify(mockDownloadsDirectoryManager, never()).getDownloadsDirectory() + verify(mockBackupDataStore).clearBackupPreferences() } @Test - fun whenDDGDirectoryIsFoundThenUpdateVariantForReturningUser() = runTest { - whenever(mockAppBuildConfig.sdkInt).thenReturn(Build.VERSION_CODES.R) - val mockDownloadsDirectory: File = mock { - on { list() } doReturn arrayOf("DuckDuckGo") - } - whenever(mockDownloadsDirectoryManager.getDownloadsDirectory()).thenReturn(mockDownloadsDirectory) + fun whenIsAppReinstallThenUpdateVariantForReturningUser() = runTest { + whenever(mockAppBuildConfig.isAppReinstall()).thenReturn(true) testee.beforeAtbInit() verify(mockStatisticsDataStore).variant = REINSTALL_VARIANT - assertTrue(isReturningUserChecked()) } @Test - fun whenDDGDirectoryIsNotFoundThenVariantForReturningUserIsNotSet() = runTest { - whenever(mockAppBuildConfig.sdkInt).thenReturn(Build.VERSION_CODES.R) - val mockDownloadsDirectory: File = mock { - on { list() } doReturn emptyArray() - } - whenever(mockDownloadsDirectoryManager.getDownloadsDirectory()).thenReturn(mockDownloadsDirectory) + fun whenIsNotAppReinstallThenVariantForReturningUserIsNotSet() = runTest { + whenever(mockAppBuildConfig.isAppReinstall()).thenReturn(false) testee.beforeAtbInit() verify(mockStatisticsDataStore, never()).variant = REINSTALL_VARIANT - assertTrue(isReturningUserChecked()) - } - - @Test - fun whenDDGDirectoryIsNotFoundThenCreateIt() = runTest { - whenever(mockAppBuildConfig.sdkInt).thenReturn(Build.VERSION_CODES.R) - val mockDownloadsDirectory: File = mock { - on { list() } doReturn emptyArray() - } - whenever(mockDownloadsDirectoryManager.getDownloadsDirectory()).thenReturn(mockDownloadsDirectory) - - testee.beforeAtbInit() - - verify(mockDownloadsDirectoryManager).createNewDirectory("DuckDuckGo") - assertTrue(isReturningUserChecked()) - } - - private fun isReturningUserChecked(): Boolean { - return preferences.getBoolean("RETURNING_USER_CHECKED_TAG", false) - } - - private fun setReturningUserChecked() { - preferences.edit(commit = true) { putBoolean("RETURNING_USER_CHECKED_TAG", true) } } } diff --git a/installation/installation-impl/build.gradle b/installation/installation-impl/build.gradle index 533afed819ef..731d5f64dc22 100644 --- a/installation/installation-impl/build.gradle +++ b/installation/installation-impl/build.gradle @@ -30,6 +30,7 @@ dependencies { implementation project(path: ':di') implementation project(path: ':common-utils') implementation project(path: ':statistics-api') + implementation project(path: ':browser-api') implementation AndroidX.core.ktx implementation KotlinX.coroutines.core implementation Google.dagger diff --git a/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/InstallSourceExtractor.kt b/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/RealInstallSourceExtractor.kt similarity index 91% rename from installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/InstallSourceExtractor.kt rename to installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/RealInstallSourceExtractor.kt index 7c0ba0a27756..e35c2f04bb23 100644 --- a/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/InstallSourceExtractor.kt +++ b/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/RealInstallSourceExtractor.kt @@ -18,7 +18,6 @@ package com.duckduckgo.installation.impl.installer import android.annotation.SuppressLint import android.content.Context -import android.os.Build.VERSION_CODES import androidx.annotation.RequiresApi import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.di.scopes.AppScope @@ -26,6 +25,10 @@ import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject interface InstallSourceExtractor { + + /** + * Extracts the installer package name from the PackageManager. + */ fun extract(): String? } @@ -37,7 +40,7 @@ class RealInstallSourceExtractor @Inject constructor( @SuppressLint("NewApi") override fun extract(): String? { - return if (appBuildConfig.sdkInt >= VERSION_CODES.R) { + return if (appBuildConfig.sdkInt >= 30) { installationSourceModern(context.packageName) } else { installationSourceLegacy(context.packageName) @@ -49,7 +52,7 @@ class RealInstallSourceExtractor @Inject constructor( return context.packageManager.getInstallerPackageName(packageName) } - @RequiresApi(VERSION_CODES.R) + @RequiresApi(30) private fun installationSourceModern(packageName: String): String? { return context.packageManager.getInstallSourceInfo(packageName).installingPackageName } diff --git a/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/aura/AuraExperimentFeature.kt b/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/aura/AuraExperimentFeature.kt new file mode 100644 index 000000000000..27c43d7f2d3a --- /dev/null +++ b/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/aura/AuraExperimentFeature.kt @@ -0,0 +1,31 @@ +/* + * 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.installation.impl.installer.aura + +import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.Toggle + +@ContributesRemoteFeature( + scope = AppScope::class, + featureName = "auraExperiment", +) +interface AuraExperimentFeature { + + @Toggle.DefaultValue(false) + fun self(): Toggle +} diff --git a/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/aura/AuraExperimentListJsonParser.kt b/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/aura/AuraExperimentListJsonParser.kt new file mode 100644 index 000000000000..d7e4670ef778 --- /dev/null +++ b/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/aura/AuraExperimentListJsonParser.kt @@ -0,0 +1,62 @@ +/* + * 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.installation.impl.installer.aura + +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import javax.inject.Inject +import kotlinx.coroutines.withContext + +data class Packages(val list: List = emptyList()) + +interface AuraExperimentListJsonParser { + suspend fun parseJson(json: String?): Packages +} + +@ContributesBinding(AppScope::class) +class AuraExperimentListJsonParserImpl @Inject constructor( + private val dispatcherProvider: DispatcherProvider, +) : AuraExperimentListJsonParser { + + private val jsonAdapter by lazy { buildJsonAdapter() } + + private fun buildJsonAdapter(): JsonAdapter { + val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + return moshi.adapter(SettingsJson::class.java) + } + + override suspend fun parseJson(json: String?): Packages = withContext(dispatcherProvider.io()) { + if (json == null) return@withContext Packages() + + kotlin.runCatching { + val parsed = jsonAdapter.fromJson(json) + parsed?.asPackages() ?: Packages() + }.getOrDefault(Packages()) + } + + private fun SettingsJson.asPackages(): Packages { + return Packages(packages.map { it }) + } + + private data class SettingsJson( + val packages: List, + ) +} diff --git a/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/aura/AuraExperimentManager.kt b/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/aura/AuraExperimentManager.kt new file mode 100644 index 000000000000..7191b1674e7b --- /dev/null +++ b/installation/installation-impl/src/main/java/com/duckduckgo/installation/impl/installer/aura/AuraExperimentManager.kt @@ -0,0 +1,66 @@ +/* + * 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.installation.impl.installer.aura + +import com.duckduckgo.anvil.annotations.PriorityKey +import com.duckduckgo.app.statistics.AtbInitializerListener +import com.duckduckgo.app.statistics.store.StatisticsDataStore +import com.duckduckgo.browser.api.referrer.AppReferrer +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.installation.impl.installer.InstallSourceExtractor +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import javax.inject.Inject +import kotlinx.coroutines.withContext + +@ContributesMultibinding(AppScope::class) +@PriorityKey(AtbInitializerListener.PRIORITY_AURA_EXPERIMENT_MANAGER) +@SingleInstanceIn(AppScope::class) +class AuraExperimentManager @Inject constructor( + private val auraExperimentFeature: AuraExperimentFeature, + private val auraExperimentListJsonParser: AuraExperimentListJsonParser, + private val installSourceExtractor: InstallSourceExtractor, + private val statisticsDataStore: StatisticsDataStore, + private val appReferrer: AppReferrer, + private val dispatcherProvider: DispatcherProvider, +) : AtbInitializerListener { + + override suspend fun beforeAtbInit() { + initialize() + } + + override fun beforeAtbInitTimeoutMillis(): Long = MAX_WAIT_TIME_MS + + private suspend fun initialize() = withContext(dispatcherProvider.io()) { + if (auraExperimentFeature.self().isEnabled()) { + installSourceExtractor.extract()?.let { source -> + val settings = auraExperimentFeature.self().getSettings() + val packages = auraExperimentListJsonParser.parseJson(settings).list + if (packages.contains(source)) { + statisticsDataStore.variant = VARIANT + appReferrer.setOriginAttributeCampaign(ORIGIN) + } + } + } + } + companion object { + const val VARIANT = "mq" + const val ORIGIN = "funnel_app_aurapaid_android" + const val MAX_WAIT_TIME_MS = 1_500L + } +} diff --git a/installation/installation-impl/src/test/java/com/duckduckgo/installation/impl/installer/aura/AuraExperimentListJsonParserImplTest.kt b/installation/installation-impl/src/test/java/com/duckduckgo/installation/impl/installer/aura/AuraExperimentListJsonParserImplTest.kt new file mode 100644 index 000000000000..29764e9e8f64 --- /dev/null +++ b/installation/installation-impl/src/test/java/com/duckduckgo/installation/impl/installer/aura/AuraExperimentListJsonParserImplTest.kt @@ -0,0 +1,74 @@ +/* + * 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.installation.impl.installer.com.duckduckgo.installation.impl.installer.aura + +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.test.FileUtilities +import com.duckduckgo.installation.impl.installer.aura.AuraExperimentListJsonParserImpl +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test + +class AuraExperimentListJsonParserImplTest { + + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private val testee = AuraExperimentListJsonParserImpl(coroutineTestRule.testDispatcherProvider) + + @Test + fun whenGibberishInputThenReturnsReturnsEmptyPackages() = runTest { + val result = testee.parseJson("invalid json") + assertTrue(result.list.isEmpty()) + } + + @Test + fun whenInstallerListIsMissingFieldThenReturnsEmptyPackages() = runTest { + val result = testee.parseJson("{}") + assertTrue(result.list.isEmpty()) + } + + @Test + fun whenInstallerListIsEmptyThenReturnsEmptyPackages() = runTest { + val result = testee.parseJson("auraExperiment_emptyList".loadJsonFile()) + assertTrue(result.list.isEmpty()) + } + + @Test + fun whenInstallerListHasSingleEntryThenReturnsSinglePackage() = runTest { + val result = testee.parseJson("auraExperiment_singleEntryList".loadJsonFile()) + assertEquals(1, result.list.size) + assertEquals("a.b.c", result.list[0]) + } + + @Test + fun whenInstallerListHasMultipleEntriesThenReturnsMultiplePackages() = runTest { + val result = testee.parseJson("auraExperiment_multipleEntryList".loadJsonFile()) + assertEquals(3, result.list.size) + assertEquals("a.b.c", result.list[0]) + assertEquals("d.e.f", result.list[1]) + assertEquals("g.h.i", result.list[2]) + } + + private fun String.loadJsonFile(): String { + return FileUtilities.loadText( + AuraExperimentListJsonParserImplTest::class.java.classLoader!!, + "json/$this.json", + ) + } +} diff --git a/installation/installation-impl/src/test/java/com/duckduckgo/installation/impl/installer/aura/AuraExperimentManagerTest.kt b/installation/installation-impl/src/test/java/com/duckduckgo/installation/impl/installer/aura/AuraExperimentManagerTest.kt new file mode 100644 index 000000000000..dd09e6cee6bf --- /dev/null +++ b/installation/installation-impl/src/test/java/com/duckduckgo/installation/impl/installer/aura/AuraExperimentManagerTest.kt @@ -0,0 +1,104 @@ +/* + * 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.installation.impl.installer.com.duckduckgo.installation.impl.installer.aura + +import com.duckduckgo.app.statistics.store.StatisticsDataStore +import com.duckduckgo.browser.api.referrer.AppReferrer +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.installation.impl.installer.InstallSourceExtractor +import com.duckduckgo.installation.impl.installer.aura.AuraExperimentFeature +import com.duckduckgo.installation.impl.installer.aura.AuraExperimentListJsonParser +import com.duckduckgo.installation.impl.installer.aura.AuraExperimentManager +import com.duckduckgo.installation.impl.installer.aura.Packages +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.* + +class AuraExperimentManagerTest { + + @get:Rule + var coroutinesTestRule = CoroutineTestRule() + + private val auraExperimentFeature: AuraExperimentFeature = mock() + private val auraExperimentListJsonParser: AuraExperimentListJsonParser = mock() + private val installSourceExtractor: InstallSourceExtractor = mock() + private val statisticsDataStore: StatisticsDataStore = mock() + private val appReferrer: AppReferrer = mock() + private val toggle: Toggle = mock() + + private lateinit var testee: AuraExperimentManager + + @Before + fun setup() { + testee = AuraExperimentManager( + auraExperimentFeature, + auraExperimentListJsonParser, + installSourceExtractor, + statisticsDataStore, + appReferrer, + coroutinesTestRule.testDispatcherProvider, + ) + whenever(auraExperimentFeature.self()).thenReturn(toggle) + } + + @Test + fun whenFeatureIsDisabledThenInitializeDoesNothing() = runTest { + whenever(toggle.isEnabled()).thenReturn(false) + + testee.beforeAtbInit() + + verifyNoInteractions(auraExperimentListJsonParser, installSourceExtractor, statisticsDataStore, appReferrer) + } + + @Test + fun whenFeatureIsEnabledAndInstallSourceIsNullThenInitializeDoesNothing() = runTest { + whenever(toggle.isEnabled()).thenReturn(true) + whenever(installSourceExtractor.extract()).thenReturn(null) + + testee.beforeAtbInit() + + verifyNoInteractions(auraExperimentListJsonParser, statisticsDataStore, appReferrer) + } + + @Test + fun whenFeatureIsEnabledAndInstallSourceNotInPackagesThenInitializeDoesNothing() = runTest { + whenever(toggle.isEnabled()).thenReturn(true) + whenever(installSourceExtractor.extract()).thenReturn("x.y.z") + whenever(toggle.getSettings()).thenReturn("json") + whenever(auraExperimentListJsonParser.parseJson("json")).thenReturn(Packages(list = listOf("a.b.c"))) + + testee.beforeAtbInit() + + verifyNoInteractions(statisticsDataStore, appReferrer) + } + + @Test + fun whenFeatureIsEnabledAndInstallSourceInPackagesThenSetVariantAndOrigin() = runTest { + whenever(toggle.isEnabled()).thenReturn(true) + whenever(installSourceExtractor.extract()).thenReturn("a.b.c") + whenever(toggle.getSettings()).thenReturn("json") + whenever(auraExperimentListJsonParser.parseJson("json")).thenReturn(Packages(list = listOf("a.b.c"))) + + testee.beforeAtbInit() + + verify(statisticsDataStore).variant = AuraExperimentManager.VARIANT + verify(appReferrer).setOriginAttributeCampaign(AuraExperimentManager.ORIGIN) + } +} diff --git a/installation/installation-impl/src/test/resources/json/auraExperiment_emptyList.json b/installation/installation-impl/src/test/resources/json/auraExperiment_emptyList.json new file mode 100644 index 000000000000..897f681263f5 --- /dev/null +++ b/installation/installation-impl/src/test/resources/json/auraExperiment_emptyList.json @@ -0,0 +1,3 @@ +{ + "packages": [] +} \ No newline at end of file diff --git a/installation/installation-impl/src/test/resources/json/auraExperiment_multipleEntryList.json b/installation/installation-impl/src/test/resources/json/auraExperiment_multipleEntryList.json new file mode 100644 index 000000000000..86824478ca39 --- /dev/null +++ b/installation/installation-impl/src/test/resources/json/auraExperiment_multipleEntryList.json @@ -0,0 +1,7 @@ +{ + "packages": [ + "a.b.c", + "d.e.f", + "g.h.i" + ] +} \ No newline at end of file diff --git a/installation/installation-impl/src/test/resources/json/auraExperiment_singleEntryList.json b/installation/installation-impl/src/test/resources/json/auraExperiment_singleEntryList.json new file mode 100644 index 000000000000..d2e903b75995 --- /dev/null +++ b/installation/installation-impl/src/test/resources/json/auraExperiment_singleEntryList.json @@ -0,0 +1,3 @@ +{ + "packages": ["a.b.c"] +} \ No newline at end of file diff --git a/statistics/statistics-api/src/main/java/com/duckduckgo/app/statistics/AtbInitializerListener.kt b/statistics/statistics-api/src/main/java/com/duckduckgo/app/statistics/AtbInitializerListener.kt index 42fa9ca95933..ecef62ac98e3 100644 --- a/statistics/statistics-api/src/main/java/com/duckduckgo/app/statistics/AtbInitializerListener.kt +++ b/statistics/statistics-api/src/main/java/com/duckduckgo/app/statistics/AtbInitializerListener.kt @@ -23,4 +23,9 @@ interface AtbInitializerListener { /** @return the timeout in milliseconds after which [beforeAtbInit] will be stopped */ fun beforeAtbInitTimeoutMillis(): Long + + companion object { + const val PRIORITY_REINSTALL_LISTENER = 10 + const val PRIORITY_AURA_EXPERIMENT_MANAGER = 20 + } } diff --git a/statistics/statistics-impl/build.gradle b/statistics/statistics-impl/build.gradle index 48f83290a0ef..df3c7b874624 100644 --- a/statistics/statistics-impl/build.gradle +++ b/statistics/statistics-impl/build.gradle @@ -66,6 +66,7 @@ dependencies { testImplementation Testing.junit4 testImplementation "org.mockito.kotlin:mockito-kotlin:_" + testImplementation "androidx.lifecycle:lifecycle-runtime-testing:_" testImplementation project(path: ':common-test') testImplementation project(':data-store-test') testImplementation CashApp.turbine diff --git a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/AtbInitializer.kt b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/AtbInitializer.kt index 700533d76f1d..b3eaa93c34d9 100644 --- a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/AtbInitializer.kt +++ b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/AtbInitializer.kt @@ -16,14 +16,14 @@ package com.duckduckgo.app.statistics -import androidx.annotation.VisibleForTesting import androidx.lifecycle.LifecycleOwner +import com.duckduckgo.anvil.annotations.ContributesPluginPoint import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver import com.duckduckgo.app.statistics.api.StatisticsUpdater import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.di.DaggerSet +import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin import com.squareup.anvil.annotations.ContributesMultibinding @@ -47,25 +47,15 @@ class AtbInitializer @Inject constructor( @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val statisticsDataStore: StatisticsDataStore, private val statisticsUpdater: StatisticsUpdater, - private val listeners: DaggerSet, + private val listeners: PluginPoint, private val dispatcherProvider: DispatcherProvider, ) : MainProcessLifecycleObserver, PrivacyConfigCallbackPlugin { override fun onResume(owner: LifecycleOwner) { - appCoroutineScope.launch(dispatcherProvider.io()) { initialize() } + appCoroutineScope.launch(dispatcherProvider.io()) { refreshAppRetentionAtb() } } - @VisibleForTesting - suspend fun initialize() { - Timber.v("Initialize ATB") - listeners.forEach { - withTimeoutOrNull(it.beforeAtbInitTimeoutMillis()) { it.beforeAtbInit() } - } - - initializeAtb() - } - - private fun initializeAtb() { + private fun refreshAppRetentionAtb() { if (statisticsDataStore.hasInstallationStatistics) { statisticsUpdater.refreshAppRetentionAtb() } @@ -73,8 +63,20 @@ class AtbInitializer @Inject constructor( override fun onPrivacyConfigDownloaded() { if (!statisticsDataStore.hasInstallationStatistics) { - // First time we initializeAtb - statisticsUpdater.initializeAtb() + appCoroutineScope.launch(dispatcherProvider.io()) { + Timber.v("Initialize ATB") + listeners.getPlugins().forEach { + withTimeoutOrNull(it.beforeAtbInitTimeoutMillis()) { it.beforeAtbInit() } + } + // First time we initializeAtb + statisticsUpdater.initializeAtb() + } } } } + +@ContributesPluginPoint( + scope = AppScope::class, + boundType = AtbInitializerListener::class, +) +private interface AtbInitializerListenerTrigger diff --git a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/user_segments/SegmentCalculation.kt b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/user_segments/SegmentCalculation.kt index c79a948c15d4..3efe38ac2ebc 100644 --- a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/user_segments/SegmentCalculation.kt +++ b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/user_segments/SegmentCalculation.kt @@ -19,9 +19,9 @@ package com.duckduckgo.app.statistics.user_segments import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.app.statistics.user_segments.SegmentCalculation.ActivityType import com.duckduckgo.app.statistics.user_segments.SegmentCalculation.UserSegment +import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.experiments.api.VariantManager import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject import kotlinx.coroutines.withContext @@ -90,11 +90,9 @@ interface SegmentCalculation { class RealSegmentCalculation @Inject constructor( private val dispatcherProvider: DispatcherProvider, private val store: StatisticsDataStore, - private val variantManager: VariantManager, + private val appBuildConfig: AppBuildConfig, ) : SegmentCalculation { - private val returningAtb: Boolean - get() = variantManager.getVariantKey() == "ru" private val cohortAtb: String by lazy { store.atb?.version!! } @@ -270,7 +268,7 @@ class RealSegmentCalculation @Inject constructor( } } - if (this.returningAtb && newSetAtb.asNumber() <= cohortAtb.asNumber() + 28) { + if (appBuildConfig.isAppReinstall() && newSetAtb.asNumber() <= cohortAtb.asNumber() + 28) { segments.add("reinstaller") } diff --git a/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/AtbInitializerTest.kt b/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/AtbInitializerTest.kt index a346fcb8f985..9d8841b39578 100644 --- a/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/AtbInitializerTest.kt +++ b/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/AtbInitializerTest.kt @@ -16,9 +16,11 @@ package com.duckduckgo.app.statistics +import androidx.lifecycle.testing.TestLifecycleOwner import com.duckduckgo.app.statistics.api.StatisticsUpdater import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.utils.plugins.PluginPoint import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.delay @@ -39,6 +41,17 @@ class AtbInitializerTest { private val statisticsDataStore: StatisticsDataStore = mock() private val statisticsUpdater: StatisticsUpdater = mock() private var atbInitializerListener = FakeAtbInitializerListener() + private val lifecycleOwner = TestLifecycleOwner() + private val listeners = object : PluginPoint { + override fun getPlugins(): Collection { + return setOf(atbInitializerListener) + } + } + private val emptyListeners = object : PluginPoint { + override fun getPlugins(): Collection { + return emptyList() + } + } @Test fun whenReferrerInformationInstantlyAvailableThenAtbInitialized() = runTest { @@ -57,7 +70,7 @@ class AtbInitializerTest { coroutineRule.testScope, statisticsDataStore, statisticsUpdater, - setOf(atbInitializerListener), + emptyListeners, coroutineRule.testDispatcherProvider, ) @@ -74,11 +87,11 @@ class AtbInitializerTest { coroutineRule.testScope, statisticsDataStore, statisticsUpdater, - setOf(atbInitializerListener), + listeners, coroutineRule.testDispatcherProvider, ) - testee.initialize() + testee.onResume(lifecycleOwner) verify(statisticsUpdater, never()).initializeAtb() } @@ -87,7 +100,7 @@ class AtbInitializerTest { fun whenAlreadyInitializedThenRefreshCalled() = runTest { configureAlreadyInitialized() - testee.initialize() + testee.onResume(lifecycleOwner) verify(statisticsUpdater).refreshAppRetentionAtb() } @@ -102,7 +115,7 @@ class AtbInitializerTest { } @Test - fun givenNeverInstallationStatisticsWhenOnPrivacyConfigDownloadedThenAtbInitialized() = runTest { + fun givenNeverInstallationStatisticsWhenOnPrivacyConfigDownloadedThenAuraExperimentAndAtbInitialized() = runTest { configureNeverInitialized() testee.onPrivacyConfigDownloaded() @@ -116,7 +129,7 @@ class AtbInitializerTest { coroutineRule.testScope, statisticsDataStore, statisticsUpdater, - setOf(atbInitializerListener), + listeners, coroutineRule.testDispatcherProvider, ) } @@ -127,7 +140,7 @@ class AtbInitializerTest { coroutineRule.testScope, statisticsDataStore, statisticsUpdater, - setOf(atbInitializerListener), + listeners, coroutineRule.testDispatcherProvider, ) } diff --git a/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/user_segments/SegmentCalculationTest.kt b/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/user_segments/SegmentCalculationTest.kt index 77d8be4c4f0b..bd6bac20db44 100644 --- a/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/user_segments/SegmentCalculationTest.kt +++ b/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/user_segments/SegmentCalculationTest.kt @@ -6,10 +6,10 @@ import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.app.statistics.user_segments.SegmentCalculation.ActivityType.APP_USE import com.duckduckgo.app.statistics.user_segments.SegmentCalculation.ActivityType.SEARCH +import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.FileUtilities.loadText import com.duckduckgo.data.store.api.FakeSharedPreferencesProvider -import com.duckduckgo.experiments.api.VariantManager import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi import com.squareup.moshi.Types @@ -53,7 +53,7 @@ class SegmentCalculationTest(private val input: TestInput) { private lateinit var usageHistory: UsageHistory private lateinit var atbStore: StatisticsDataStore - private val mockVariantManager = mock() + private val appBuildConfig = mock() private val mockPixel: Pixel = mock() private val crashLogger: CrashLogger = org.mockito.kotlin.mock() @@ -64,9 +64,9 @@ class SegmentCalculationTest(private val input: TestInput) { private lateinit var userSegmentsPixelSender: UserSegmentsPixelSender @Before - fun setup() { + fun setup() = runTest { atbStore = FakeStatisticsDataStore() - whenever(mockVariantManager.getVariantKey()).thenReturn(null) + whenever(appBuildConfig.isAppReinstall()).thenReturn(false) usageHistory = SegmentStoreModule().provideSegmentStore( FakeSharedPreferencesProvider(), @@ -76,7 +76,7 @@ class SegmentCalculationTest(private val input: TestInput) { segmentCalculation = RealSegmentCalculation( coroutineTestRule.testDispatcherProvider, atbStore, - mockVariantManager, + appBuildConfig, ) userSegmentsPixelSender = UserSegmentsPixelSender( usageHistory, @@ -93,7 +93,7 @@ class SegmentCalculationTest(private val input: TestInput) { // prepping test atbStore.atb = Atb(input.client.atb.removeSuffix("ru")) if (input.client.atb.contains("ru")) { - whenever(mockVariantManager.getVariantKey()).thenReturn("ru") + whenever(appBuildConfig.isAppReinstall()).thenReturn(true) } input.client.usage.forEachIndexed { index, usage -> @@ -115,7 +115,7 @@ class SegmentCalculationTest(private val input: TestInput) { // prepping test atbStore.atb = Atb(input.client.atb.removeSuffix("ru")) if (input.client.atb.contains("ru")) { - whenever(mockVariantManager.getVariantKey()).thenReturn("ru") + whenever(appBuildConfig.isAppReinstall()).thenReturn(true) } var oldAtb: String = atbStore.atb!!.version input.client.usage.forEachIndexed { index, usage ->