diff --git a/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesRemoteFeatureCodeGenerator.kt b/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesRemoteFeatureCodeGenerator.kt index 3715de94ebdd..877dbe09d5aa 100644 --- a/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesRemoteFeatureCodeGenerator.kt +++ b/anvil/anvil-compiler/src/main/java/com/duckduckgo/anvil/compiler/ContributesRemoteFeatureCodeGenerator.kt @@ -106,6 +106,7 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { .build(), ) .addParameter("appBuildConfig", appBuildConfig.asClassName(module)) + .addParameter("variantManager", variantManager.asClassName(module)) .addCode( """ return %T.Builder() @@ -113,6 +114,9 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { .appVersionProvider({ appBuildConfig.versionCode }) .flavorNameProvider({ appBuildConfig.flavor.name }) .featureName(%S) + .appVariantProvider({ appBuildConfig.variantName }) + // save empty variants will force the default variant to be set + .forceDefaultVariantProvider({ variantManager.saveVariants(emptyList()) }) .build() .create(%T::class.java) """.trimIndent(), @@ -256,6 +260,7 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { ) .addParameter("feature", dagger.Lazy::class.asClassName().parameterizedBy(boundType.asClassName())) .addParameter("appBuildConfig", appBuildConfig.asClassName(module)) + .addParameter("variantManager", variantManager.asClassName(module)) .addParameter("context", context.asClassName(module)) .build(), ) @@ -289,6 +294,11 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { .initializer("appBuildConfig") .build(), ) + .addProperty( + PropertySpec.builder("variantManager", variantManager.asClassName(module), KModifier.PRIVATE) + .initializer("variantManager") + .build(), + ) .addProperty( PropertySpec.builder("context", context.asClassName(module), KModifier.PRIVATE) .initializer("context") @@ -309,6 +319,7 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { .addFunction(createInvokeMethod(boundType)) .addType(createJsonRolloutDataClass(generatedPackage, module)) .addType(createJsonRolloutStepDataClass(generatedPackage, module)) + .addType(createJsonToggleTargetDataClass(generatedPackage, module)) .addType(createJsonToggleDataClass(generatedPackage, module)) .addType(createJsonFeatureDataClass(generatedPackage, module)) .addType(createJsonExceptionDataClass(generatedPackage, module)) @@ -416,6 +427,7 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { remoteEnableState = isEnabled, enable = isEnabled, minSupportedVersion = feature.minSupportedVersion, + targets = emptyList(), ) ) @@ -432,6 +444,11 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { val previousRolloutStep = previousState?.rolloutStep val newStateValue = (jsonToggle.state == "enabled" || (appBuildConfig.flavor == %T && jsonToggle.state == "internal")) + val targets = jsonToggle?.targets?.map { target -> + Toggle.State.Target( + variantKey = target.variantKey, + ) + } ?: emptyList() this.feature.get().invokeMethod(subfeature.key).setEnabled( Toggle.State( remoteEnableState = newStateValue, @@ -439,6 +456,7 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { minSupportedVersion = jsonToggle.minSupportedVersion?.toInt(), rollout = jsonToggle?.rollout?.steps?.map { it.percent }, rolloutStep = previousRolloutStep, + targets = targets, ), ) } catch(e: Throwable) { @@ -613,6 +631,22 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { .build() } + private fun createJsonToggleTargetDataClass( + generatedPackage: String, + module: ModuleDescriptor, + ): TypeSpec { + return TypeSpec.classBuilder(FqName("$generatedPackage.JsonToggleTarget").asClassName(module)) + .addModifiers(KModifier.PRIVATE) + .addModifiers(KModifier.DATA) + .primaryConstructor( + FunSpec.constructorBuilder() + .addParameter("variantKey", String::class.asClassName()) + .build(), + ) + .addProperty(PropertySpec.builder("variantKey", String::class.asClassName()).initializer("variantKey").build()) + .build() + } + private fun createJsonToggleDataClass( generatedPackage: String, module: ModuleDescriptor, @@ -634,6 +668,10 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { "rollout", FqName("JsonToggleRollout").asClassName(module).copy(nullable = true), ) + .addParameter( + "targets", + List::class.asClassName().parameterizedBy(FqName("JsonToggleTarget").asClassName(module)), + ) .build(), ) .addProperty( @@ -654,6 +692,12 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { .initializer("rollout") .build(), ) + .addProperty( + PropertySpec + .builder("targets", List::class.asClassName().parameterizedBy(FqName("JsonToggleTarget").asClassName(module))) + .initializer("targets") + .build(), + ) .build() } @@ -944,6 +988,7 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { private val context = FqName("android.content.Context") private val privacyFeaturePlugin = FqName("com.duckduckgo.privacy.config.api.PrivacyFeaturePlugin") private val appBuildConfig = FqName("com.duckduckgo.appbuildconfig.api.AppBuildConfig") + private val variantManager = FqName("com.duckduckgo.experiments.api.VariantManager") private val buildFlavorInternal = FqName("com.duckduckgo.appbuildconfig.api.BuildFlavor.INTERNAL") private val moshi = FqName("com.squareup.moshi.Moshi") private val jsonObjectAdapter = FqName("JSONObjectAdapter") 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 e140f63a61b8..d1dc85328953 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 @@ -32,6 +32,11 @@ interface AppBuildConfig { val model: String val deviceLocale: Locale val isDefaultVariantForced: Boolean + + /** + * You should call [variantName] in a background thread + */ + val variantName: String? } enum class BuildFlavor { diff --git a/app/build.gradle b/app/build.gradle index 72cb54768d44..62eea9a006e7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -187,6 +187,8 @@ dependencies { implementation project(':app-build-config-api') implementation project(':browser-api') implementation project(':statistics') + implementation project(':experiments-api') + implementation project(':experiments-impl') implementation project(':common-utils') implementation project(':app-store') implementation project(':common-ui') diff --git a/app/src/androidTest/java/com/duckduckgo/app/integration/AtbIntegrationTest.kt b/app/src/androidTest/java/com/duckduckgo/app/integration/AtbIntegrationTest.kt index 8c8ed30bbb02..2ca534481a17 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/integration/AtbIntegrationTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/integration/AtbIntegrationTest.kt @@ -19,8 +19,6 @@ package com.duckduckgo.app.integration import androidx.test.filters.LargeTest import androidx.test.platform.app.InstrumentationRegistry import com.duckduckgo.app.getDaggerComponent -import com.duckduckgo.app.statistics.Variant -import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.api.RefreshRetentionAtbPlugin import com.duckduckgo.app.statistics.api.StatisticsRequester import com.duckduckgo.app.statistics.api.StatisticsService @@ -28,8 +26,12 @@ import com.duckduckgo.app.statistics.model.Atb import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.app.statistics.store.StatisticsSharedPreferences import com.duckduckgo.autofill.api.email.EmailManager +import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.InstantSchedulersRule import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.experiments.api.VariantManager +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope import org.junit.Assert.* import org.junit.Before import org.junit.Rule @@ -44,6 +46,7 @@ import org.mockito.kotlin.whenever * Would normally have separate tests for each assertion, but these tests are relatively expensive to run. */ @LargeTest +@ExperimentalCoroutinesApi class AtbIntegrationTest { private lateinit var mockVariantManager: VariantManager @@ -55,13 +58,16 @@ class AtbIntegrationTest { @get:Rule val schedulers = InstantSchedulersRule() + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + @Before fun before() { mockVariantManager = mock() statisticsStore = StatisticsSharedPreferences(InstrumentationRegistry.getInstrumentation().targetContext) statisticsStore.clearAtb() - whenever(mockVariantManager.getVariant()).thenReturn(Variant("ma", 100.0, filterBy = { true })) + whenever(mockVariantManager.getVariantKey()).thenReturn("ma") service = getDaggerComponent().retrofit().create(StatisticsService::class.java) val plugins = object : PluginPoint { @@ -69,7 +75,15 @@ class AtbIntegrationTest { return listOf() } } - testee = StatisticsRequester(statisticsStore, service, mockVariantManager, plugins, emailManager) + testee = StatisticsRequester( + statisticsStore, + service, + mockVariantManager, + plugins, + emailManager, + TestScope(), + coroutineTestRule.testDispatcherProvider, + ) } @Test diff --git a/app/src/androidTest/java/com/duckduckgo/app/notification/NotificationRegistrarTest.kt b/app/src/androidTest/java/com/duckduckgo/app/notification/NotificationRegistrarTest.kt index 511785a6d3ed..0221efbd84ae 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/notification/NotificationRegistrarTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/notification/NotificationRegistrarTest.kt @@ -22,18 +22,22 @@ import com.duckduckgo.app.notification.model.NotificationPlugin import com.duckduckgo.app.notification.model.SchedulableNotificationPlugin import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.settings.db.SettingsDataStore -import com.duckduckgo.app.statistics.VariantManager -import com.duckduckgo.app.statistics.VariantManager.Companion.DEFAULT_VARIANT import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.experiments.api.VariantManager import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import org.junit.Before import org.junit.Rule import org.junit.Test -import org.mockito.kotlin.* +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever @ExperimentalCoroutinesApi class NotificationRegistrarTest { @@ -54,7 +58,7 @@ class NotificationRegistrarTest { @Before fun before() { - whenever(mockVariantManager.getVariant(any())).thenReturn(DEFAULT_VARIANT) + whenever(mockVariantManager.getVariantKey()).thenReturn("DEFAULT_VARIANT") whenever(appBuildConfig.sdkInt).thenReturn(30) testee = NotificationRegistrar( TestScope(), diff --git a/app/src/androidTest/java/com/duckduckgo/app/statistics/api/RxPixelSenderTest.kt b/app/src/androidTest/java/com/duckduckgo/app/statistics/api/RxPixelSenderTest.kt index 5a73d20b2513..739cf93d8667 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/statistics/api/RxPixelSenderTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/statistics/api/RxPixelSenderTest.kt @@ -21,8 +21,6 @@ import androidx.lifecycle.LifecycleOwner import androidx.room.Room import androidx.test.platform.app.InstrumentationRegistry import com.duckduckgo.app.global.db.AppDatabase -import com.duckduckgo.app.statistics.Variant -import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.api.RxPixelSenderTest.TestPixels.TEST import com.duckduckgo.app.statistics.config.StatisticsLibraryConfig import com.duckduckgo.app.statistics.model.Atb @@ -32,6 +30,7 @@ import com.duckduckgo.app.statistics.store.PendingPixelDao import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.common.test.InstantSchedulersRule import com.duckduckgo.common.utils.device.DeviceInfo +import com.duckduckgo.experiments.api.VariantManager import io.reactivex.Completable import java.util.concurrent.TimeoutException import org.junit.After @@ -46,11 +45,9 @@ import org.mockito.kotlin.* class RxPixelSenderTest { @get:Rule - @Suppress("unused") var instantTaskExecutorRule = InstantTaskExecutorRule() @get:Rule - @Suppress("unused") val schedulers = InstantSchedulersRule() @Mock @@ -98,7 +95,7 @@ class RxPixelSenderTest { fun whenPixelFiredThenPixelServiceCalledWithCorrectAtbAndVariant() { givenPixelApiSucceeds() givenAtbVariant(Atb("atb")) - givenVariant(Variant("variant", filterBy = { true })) + givenVariant("variant") givenFormFactor(DeviceInfo.FormFactor.PHONE) testee.sendPixel(TEST.pixelName, emptyMap(), emptyMap()) @@ -130,7 +127,7 @@ class RxPixelSenderTest { fun whenPixelFiredWithAdditionalParametersThenPixelServiceCalledWithDefaultAndAdditionalParameters() { givenPixelApiSucceeds() givenAtbVariant(Atb("atb")) - givenVariant(Variant("variant", filterBy = { true })) + givenVariant("variant") givenFormFactor(DeviceInfo.FormFactor.PHONE) givenAppVersion("1.0.0") @@ -145,7 +142,7 @@ class RxPixelSenderTest { fun whenPixelFiredWithoutAdditionalParametersThenPixelServiceCalledWithOnlyDefaultParameters() { givenPixelApiSucceeds() givenAtbVariant(Atb("atb")) - givenVariant(Variant("variant", filterBy = { true })) + givenVariant("variant") givenFormFactor(DeviceInfo.FormFactor.PHONE) givenAppVersion("1.0.0") @@ -158,7 +155,7 @@ class RxPixelSenderTest { @Test fun whenPixelEnqueuedWitAdditionalParametersThenPixelEnqueuedWithParameters() { givenAtbVariant(Atb("atb")) - givenVariant(Variant("variant", filterBy = { true })) + givenVariant("variant") givenFormFactor(DeviceInfo.FormFactor.PHONE) givenAppVersion("1.0.0") val params = mapOf("param1" to "value1", "param2" to "value2") @@ -183,7 +180,7 @@ class RxPixelSenderTest { @Test fun whenPixelEnqueuedWithoutAdditionalParametersThenPixelEnqueuedWithOnlyDefaultParameters() { givenAtbVariant(Atb("atb")) - givenVariant(Variant("variant", filterBy = { true })) + givenVariant("variant") givenFormFactor(DeviceInfo.FormFactor.PHONE) givenAppVersion("1.0.0") @@ -304,8 +301,8 @@ class RxPixelSenderTest { whenever(api.fire(any(), any(), any(), any(), any(), any())).thenReturn(Completable.complete()) } - private fun givenVariant(variant: Variant) { - whenever(mockVariantManager.getVariant()).thenReturn(variant) + private fun givenVariant(variantKey: String) { + whenever(mockVariantManager.getVariantKey()).thenReturn(variantKey) } private fun givenAtbVariant(atb: Atb) { diff --git a/app/src/androidTest/java/com/duckduckgo/app/statistics/api/StatisticsRequesterJsonTest.kt b/app/src/androidTest/java/com/duckduckgo/app/statistics/api/StatisticsRequesterJsonTest.kt index 38968201a7dd..4f9e2c1c681b 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/statistics/api/StatisticsRequesterJsonTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/statistics/api/StatisticsRequesterJsonTest.kt @@ -19,21 +19,23 @@ package com.duckduckgo.app.statistics.api import android.net.Uri import androidx.core.net.toUri import androidx.test.platform.app.InstrumentationRegistry -import com.duckduckgo.app.statistics.Variant -import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.model.Atb import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.app.statistics.store.StatisticsSharedPreferences import com.duckduckgo.autofill.api.email.EmailManager +import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.FileUtilities.loadText import com.duckduckgo.common.test.InstantSchedulersRule import com.duckduckgo.common.utils.AppUrl.ParamKey import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.experiments.api.VariantManager import com.squareup.moshi.Moshi import java.net.InetAddress import java.net.InetSocketAddress import java.net.Proxy import java.util.concurrent.TimeUnit +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope import okhttp3.OkHttpClient import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -49,6 +51,7 @@ import retrofit2.Retrofit import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory import retrofit2.converter.moshi.MoshiConverterFactory +@ExperimentalCoroutinesApi class StatisticsRequesterJsonTest { private var mockVariantManager: VariantManager = mock() @@ -61,9 +64,11 @@ class StatisticsRequesterJsonTest { private val server = MockWebServer() @get:Rule - @Suppress("unused") val schedulers = InstantSchedulersRule() + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + @Before fun before() { configureStubNetworking() @@ -76,8 +81,16 @@ class StatisticsRequesterJsonTest { return listOf() } } - testee = StatisticsRequester(statisticsStore, statisticsService, mockVariantManager, plugins, mockEmailManager) - whenever(mockVariantManager.getVariant()).thenReturn(Variant("ma", 100.0, filterBy = { true })) + testee = StatisticsRequester( + statisticsStore, + statisticsService, + mockVariantManager, + plugins, + mockEmailManager, + TestScope(), + coroutineTestRule.testDispatcherProvider, + ) + whenever(mockVariantManager.getVariantKey()).thenReturn("ma") } @After diff --git a/app/src/androidTest/java/com/duckduckgo/espresso/privacy/SurrogatesTest.kt b/app/src/androidTest/java/com/duckduckgo/espresso/privacy/SurrogatesTest.kt index 887ad75a9f85..8f636fc658cc 100644 --- a/app/src/androidTest/java/com/duckduckgo/espresso/privacy/SurrogatesTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/espresso/privacy/SurrogatesTest.kt @@ -33,7 +33,6 @@ import com.duckduckgo.privacy.config.impl.network.JSONObjectAdapter import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi import java.util.concurrent.TimeUnit -import org.hamcrest.* import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Rule diff --git a/app/src/main/java/com/duckduckgo/app/about/AboutDuckDuckGoViewModel.kt b/app/src/main/java/com/duckduckgo/app/about/AboutDuckDuckGoViewModel.kt index c7aa4be2e265..7c541c0f09dc 100644 --- a/app/src/main/java/com/duckduckgo/app/about/AboutDuckDuckGoViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/about/AboutDuckDuckGoViewModel.kt @@ -21,10 +21,10 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.app.pixels.AppPixelName.* -import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.experiments.api.VariantManager import com.duckduckgo.networkprotection.api.NetworkProtectionWaitlist import com.duckduckgo.networkprotection.api.NetworkProtectionWaitlist.NetPWaitlistState import com.duckduckgo.networkprotection.api.NetworkProtectionWaitlist.NetPWaitlistState.NotUnlocked @@ -65,13 +65,13 @@ class AboutDuckDuckGoViewModel @Inject constructor( private var netPEasterEggCounter = 0 fun viewState(): Flow = viewState.onStart { - val variant = variantManager.getVariant() + val variantKey = variantManager.getVariantKey() viewModelScope.launch { viewState.emit( currentViewState().copy( networkProtectionWaitlistState = networkProtectionWaitlist.getState(), - version = obtainVersion(variant.key), + version = obtainVersion(variantKey), ), ) } @@ -129,8 +129,8 @@ class AboutDuckDuckGoViewModel @Inject constructor( return viewState.value } - private fun obtainVersion(variantKey: String): String { - val formattedVariantKey = if (variantKey.isBlank()) " " else " $variantKey " + private fun obtainVersion(variantKey: String?): String { + val formattedVariantKey = if (variantKey.isNullOrBlank()) " " else " $variantKey " return "${appBuildConfig.versionName}$formattedVariantKey(${appBuildConfig.versionCode})" } diff --git a/app/src/main/java/com/duckduckgo/app/brokensite/api/BrokenSiteSender.kt b/app/src/main/java/com/duckduckgo/app/brokensite/api/BrokenSiteSender.kt index d5adaa679399..d9378c6254a4 100644 --- a/app/src/main/java/com/duckduckgo/app/brokensite/api/BrokenSiteSender.kt +++ b/app/src/main/java/com/duckduckgo/app/brokensite/api/BrokenSiteSender.kt @@ -22,7 +22,6 @@ import com.duckduckgo.app.brokensite.model.BrokenSite import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.privacy.db.UserAllowListRepository -import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.app.trackerdetection.db.TdsMetadataDao @@ -32,6 +31,7 @@ import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.absoluteString import com.duckduckgo.common.utils.domain import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.experiments.api.VariantManager import com.duckduckgo.feature.toggles.api.FeatureToggle import com.duckduckgo.privacy.config.api.ContentBlocking import com.duckduckgo.privacy.config.api.Gpc @@ -130,7 +130,7 @@ class BrokenSiteSubmitter @Inject constructor( } private fun atbWithVariant(): String { - return statisticsStore.atb?.formatWithVariant(variantManager.getVariant()).orEmpty() + return statisticsStore.atb?.formatWithVariant(variantManager.getVariantKey()).orEmpty() } companion object { diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt index 3b03a0ad623a..99a415d5feb5 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -62,7 +62,6 @@ import com.duckduckgo.app.playstore.PlayStoreUtils import com.duckduckgo.app.settings.SettingsActivity import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.sitepermissions.SitePermissionsActivity -import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.autofill.api.emailprotection.EmailProtectionLinkVerifier @@ -106,9 +105,6 @@ open class BrowserActivity : DuckDuckGoActivity() { @Inject lateinit var ctaViewModel: CtaViewModel - @Inject - lateinit var variantManager: VariantManager - @Inject lateinit var userEventsStore: UserEventsStore 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 30762490c54a..ec33249e6723 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -155,7 +155,6 @@ import com.duckduckgo.app.global.view.toggleFullScreen import com.duckduckgo.app.location.data.LocationPermissionType import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.playstore.PlayStoreUtils -import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.FIRE_BUTTON_STATE import com.duckduckgo.app.survey.model.Survey @@ -311,9 +310,6 @@ class BrowserTabFragment : @Inject lateinit var previewPersister: WebViewPreviewPersister - @Inject - lateinit var variantManager: VariantManager - @Inject lateinit var loginDetector: DOMLoginDetector diff --git a/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoRequestRewriter.kt b/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoRequestRewriter.kt index 512951a242b5..b3f73b23ed82 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoRequestRewriter.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/DuckDuckGoRequestRewriter.kt @@ -18,10 +18,10 @@ package com.duckduckgo.app.browser import android.net.Uri import com.duckduckgo.app.referral.AppReferrerDataStore -import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.common.utils.AppUrl.ParamKey import com.duckduckgo.common.utils.AppUrl.ParamValue +import com.duckduckgo.experiments.api.VariantManager import timber.log.Timber interface RequestRewriter { @@ -68,7 +68,7 @@ class DuckDuckGoRequestRewriter( override fun addCustomQueryParams(builder: Uri.Builder) { val atb = statisticsStore.atb if (atb != null) { - builder.appendQueryParameter(ParamKey.ATB, atb.formatWithVariant(variantManager.getVariant())) + builder.appendQueryParameter(ParamKey.ATB, atb.formatWithVariant(variantManager.getVariantKey())) } val sourceValue = if (appReferrerDataStore.installedFromEuAuction) ParamValue.SOURCE_EU_AUCTION else ParamValue.SOURCE diff --git a/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt b/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt index d460005076e9..e52bfae38af2 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/di/BrowserModule.kt @@ -54,7 +54,6 @@ import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver import com.duckduckgo.app.privacy.db.PrivacyProtectionCountDao import com.duckduckgo.app.referral.AppReferrerDataStore import com.duckduckgo.app.settings.db.SettingsDataStore -import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.app.surrogates.ResourceSurrogates @@ -70,6 +69,7 @@ import com.duckduckgo.downloads.api.FileDownloader import com.duckduckgo.downloads.impl.AndroidFileDownloader import com.duckduckgo.downloads.impl.DataUriDownloader import com.duckduckgo.downloads.impl.FileDownloadCallback +import com.duckduckgo.experiments.api.VariantManager import com.duckduckgo.httpsupgrade.api.HttpsUpgrader import com.duckduckgo.privacy.config.api.AmpLinks import com.duckduckgo.privacy.config.api.Gpc 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 cec5e935f5a4..82cbcdc114fc 100644 --- a/app/src/main/java/com/duckduckgo/app/buildconfig/RealAppBuildConfig.kt +++ b/app/src/main/java/com/duckduckgo/app/buildconfig/RealAppBuildConfig.kt @@ -21,13 +21,17 @@ import com.duckduckgo.app.browser.BuildConfig import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.appbuildconfig.api.BuildFlavor import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.experiments.api.VariantManager import com.squareup.anvil.annotations.ContributesBinding +import dagger.Lazy import java.lang.IllegalStateException import java.util.* import javax.inject.Inject @ContributesBinding(AppScope::class) -class RealAppBuildConfig @Inject constructor() : AppBuildConfig { +class RealAppBuildConfig @Inject constructor( + private val variantManager: Lazy, // break any possible DI dependency cycle +) : AppBuildConfig { override val isDebug: Boolean = BuildConfig.DEBUG override val applicationId: String = BuildConfig.APPLICATION_ID override val buildType: String = BuildConfig.BUILD_TYPE @@ -56,4 +60,7 @@ class RealAppBuildConfig @Inject constructor() : AppBuildConfig { override val isDefaultVariantForced: Boolean = BuildConfig.FORCE_DEFAULT_VARIANT override val deviceLocale: Locale get() = Locale.getDefault() + + override val variantName: String? + get() = variantManager.get().getVariantKey() } diff --git a/app/src/main/java/com/duckduckgo/app/di/AppComponent.kt b/app/src/main/java/com/duckduckgo/app/di/AppComponent.kt index e7367037e00e..f77e2f8eedb9 100644 --- a/app/src/main/java/com/duckduckgo/app/di/AppComponent.kt +++ b/app/src/main/java/com/duckduckgo/app/di/AppComponent.kt @@ -58,7 +58,6 @@ import retrofit2.Retrofit ResourceSurrogateModule::class, NotificationModule::class, OnboardingModule::class, - VariantModule::class, FaviconModule::class, PrivacyModule::class, WidgetModule::class, diff --git a/app/src/main/java/com/duckduckgo/app/di/DevicePropertiesModule.kt b/app/src/main/java/com/duckduckgo/app/di/DevicePropertiesModule.kt index 3dbc52c4177c..34b90b032be9 100644 --- a/app/src/main/java/com/duckduckgo/app/di/DevicePropertiesModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/DevicePropertiesModule.kt @@ -21,7 +21,6 @@ import com.duckduckgo.app.global.install.AppInstallStore import com.duckduckgo.app.global.store.AndroidAppProperties import com.duckduckgo.app.global.store.AndroidUserBrowserProperties import com.duckduckgo.app.playstore.PlayStoreUtils -import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.app.usage.app.AppDaysUsedRepository import com.duckduckgo.app.usage.search.SearchCountDao @@ -31,6 +30,7 @@ import com.duckduckgo.browser.api.AppProperties import com.duckduckgo.browser.api.UserBrowserProperties import com.duckduckgo.common.ui.store.ThemingDataStore import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.experiments.api.VariantManager import com.duckduckgo.mobile.android.app.tracking.AppTrackingProtection import com.duckduckgo.networkprotection.api.NetworkProtectionState import com.duckduckgo.savedsites.api.SavedSitesRepository diff --git a/app/src/main/java/com/duckduckgo/app/di/NetworkModule.kt b/app/src/main/java/com/duckduckgo/app/di/NetworkModule.kt index cf424382759a..1f723d53c0d6 100644 --- a/app/src/main/java/com/duckduckgo/app/di/NetworkModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/NetworkModule.kt @@ -22,7 +22,6 @@ import com.duckduckgo.app.feedback.api.FeedbackSubmitter import com.duckduckgo.app.feedback.api.FireAndForgetFeedbackSubmitter import com.duckduckgo.app.feedback.api.SubReasonApiMapper import com.duckduckgo.app.global.api.* -import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.appbuildconfig.api.AppBuildConfig @@ -31,6 +30,7 @@ import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.common.utils.plugins.pixel.PixelInterceptorPlugin import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.experiments.api.VariantManager import com.duckduckgo.user.agent.api.UserAgentProvider import com.squareup.moshi.Moshi import dagger.Lazy diff --git a/app/src/main/java/com/duckduckgo/app/di/StatisticsModule.kt b/app/src/main/java/com/duckduckgo/app/di/StatisticsModule.kt index 47424a4fe014..613558c72b0d 100644 --- a/app/src/main/java/com/duckduckgo/app/di/StatisticsModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/StatisticsModule.kt @@ -18,13 +18,8 @@ package com.duckduckgo.app.di import android.content.Context import com.duckduckgo.app.global.db.AppDatabase -import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver -import com.duckduckgo.app.statistics.AtbInitializer -import com.duckduckgo.app.statistics.AtbInitializerListener import com.duckduckgo.app.statistics.api.* import com.duckduckgo.app.statistics.store.PendingPixelDao -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 @@ -33,8 +28,6 @@ import com.squareup.anvil.annotations.ContributesTo import dagger.Module import dagger.Provides import dagger.SingleInstanceIn -import dagger.multibindings.IntoSet -import kotlinx.coroutines.CoroutineScope @Module @ContributesTo(AppScope::class) @@ -48,19 +41,6 @@ object StatisticsModule { @Provides fun deviceInfo(context: Context): DeviceInfo = ContextDeviceInfo(context) - @Provides - @IntoSet - @SingleInstanceIn(AppScope::class) - fun atbInitializer( - @AppCoroutineScope appCoroutineScope: CoroutineScope, - statisticsDataStore: StatisticsDataStore, - statisticsUpdater: StatisticsUpdater, - listeners: DaggerSet, - dispatcherProvider: DispatcherProvider, - ): MainProcessLifecycleObserver { - return AtbInitializer(appCoroutineScope, statisticsDataStore, statisticsUpdater, listeners, dispatcherProvider) - } - @SingleInstanceIn(AppScope::class) @Provides fun pixelDao(database: AppDatabase): PendingPixelDao { diff --git a/app/src/main/java/com/duckduckgo/app/di/VariantModule.kt b/app/src/main/java/com/duckduckgo/app/di/VariantModule.kt deleted file mode 100644 index e902708729ab..000000000000 --- a/app/src/main/java/com/duckduckgo/app/di/VariantModule.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (c) 2018 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.app.di - -import com.duckduckgo.app.statistics.ExperimentationVariantManager -import com.duckduckgo.app.statistics.IndexRandomizer -import com.duckduckgo.app.statistics.VariantManager -import com.duckduckgo.app.statistics.WeightedRandomizer -import com.duckduckgo.app.statistics.store.StatisticsDataStore -import com.duckduckgo.appbuildconfig.api.AppBuildConfig -import com.duckduckgo.di.scopes.AppScope -import dagger.Module -import dagger.Provides -import dagger.SingleInstanceIn - -@Module -object VariantModule { - - @Provides - @SingleInstanceIn(AppScope::class) - fun variantManager( - statisticsDataStore: StatisticsDataStore, - weightedRandomizer: IndexRandomizer, - appBuildConfig: AppBuildConfig, - ): VariantManager = - ExperimentationVariantManager(statisticsDataStore, weightedRandomizer, appBuildConfig) - - @Provides - fun weightedRandomizer(): IndexRandomizer = WeightedRandomizer() -} diff --git a/app/src/main/java/com/duckduckgo/app/feedback/api/FeedbackSubmitter.kt b/app/src/main/java/com/duckduckgo/app/feedback/api/FeedbackSubmitter.kt index c86fdfa7596b..63930f509d6a 100644 --- a/app/src/main/java/com/duckduckgo/app/feedback/api/FeedbackSubmitter.kt +++ b/app/src/main/java/com/duckduckgo/app/feedback/api/FeedbackSubmitter.kt @@ -18,16 +18,21 @@ package com.duckduckgo.app.feedback.api import android.os.Build import com.duckduckgo.app.feedback.ui.negative.FeedbackType.MainReason -import com.duckduckgo.app.feedback.ui.negative.FeedbackType.MainReason.* +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.MainReason.APP_IS_SLOW_OR_BUGGY +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.MainReason.MISSING_BROWSING_FEATURES +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.MainReason.NOT_ENOUGH_CUSTOMIZATIONS +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.MainReason.OTHER +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.MainReason.SEARCH_NOT_GOOD_ENOUGH +import com.duckduckgo.app.feedback.ui.negative.FeedbackType.MainReason.WEBSITES_NOT_LOADING import com.duckduckgo.app.feedback.ui.negative.FeedbackType.SubReason import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.pixels.AppPixelName.FEEDBACK_NEGATIVE_SUBMISSION -import com.duckduckgo.app.statistics.VariantManager 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 java.util.* +import com.duckduckgo.experiments.api.VariantManager +import java.util.Locale import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import timber.log.Timber @@ -183,7 +188,7 @@ class FireAndForgetFeedbackSubmitter( } private fun atbWithVariant(): String { - return statisticsDataStore.atb?.formatWithVariant(variantManager.getVariant()) ?: "" + return statisticsDataStore.atb?.formatWithVariant(variantManager.getVariantKey()) ?: "" } companion object { diff --git a/app/src/main/java/com/duckduckgo/app/global/store/AndroidAppProperties.kt b/app/src/main/java/com/duckduckgo/app/global/store/AndroidAppProperties.kt index e134e7401213..c1bc02534a1d 100644 --- a/app/src/main/java/com/duckduckgo/app/global/store/AndroidAppProperties.kt +++ b/app/src/main/java/com/duckduckgo/app/global/store/AndroidAppProperties.kt @@ -19,9 +19,9 @@ package com.duckduckgo.app.global.store import android.content.Context import androidx.webkit.WebViewCompat import com.duckduckgo.app.playstore.PlayStoreUtils -import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.browser.api.AppProperties +import com.duckduckgo.experiments.api.VariantManager import timber.log.Timber class AndroidAppProperties( @@ -44,7 +44,7 @@ class AndroidAppProperties( } override fun expVariant(): String { - return variantManager.getVariant().key + return variantManager.getVariantKey().orEmpty() } override fun installedGPlay(): Boolean { diff --git a/app/src/main/java/com/duckduckgo/app/launch/LaunchBridgeActivity.kt b/app/src/main/java/com/duckduckgo/app/launch/LaunchBridgeActivity.kt index 94b0c97fc6fc..1f0ff06ba37c 100644 --- a/app/src/main/java/com/duckduckgo/app/launch/LaunchBridgeActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/launch/LaunchBridgeActivity.kt @@ -22,18 +22,13 @@ import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.browser.BrowserActivity import com.duckduckgo.app.browser.R import com.duckduckgo.app.onboarding.ui.OnboardingActivity -import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.common.ui.DuckDuckGoActivity import com.duckduckgo.di.scopes.ActivityScope -import javax.inject.Inject import kotlinx.coroutines.launch @InjectWith(ActivityScope::class) class LaunchBridgeActivity : DuckDuckGoActivity() { - @Inject - lateinit var variantManager: VariantManager - private val viewModel: LaunchViewModel by bindViewModel() override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/DefaultBrowserPage.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/DefaultBrowserPage.kt index 5650911f89e0..98b3d963bd5b 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/DefaultBrowserPage.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/DefaultBrowserPage.kt @@ -33,7 +33,6 @@ import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.browser.BrowserActivity import com.duckduckgo.app.browser.R import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserSystemSettings -import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.ui.view.button.DaxButton import com.duckduckgo.common.ui.view.show @@ -48,9 +47,6 @@ class DefaultBrowserPage : OnboardingPageFragment(R.layout.content_onboarding_de @Inject lateinit var viewModelFactory: FragmentViewModelFactory - @Inject - lateinit var variantManager: VariantManager - @Inject lateinit var appBuildConfig: AppBuildConfig diff --git a/app/src/play/java/com/duckduckgo/referral/PlayStoreAppReferrerStateListener.kt b/app/src/play/java/com/duckduckgo/referral/PlayStoreAppReferrerStateListener.kt index 0a23cdfbeb32..aa053248edf5 100644 --- a/app/src/play/java/com/duckduckgo/referral/PlayStoreAppReferrerStateListener.kt +++ b/app/src/play/java/com/duckduckgo/referral/PlayStoreAppReferrerStateListener.kt @@ -32,8 +32,9 @@ import com.duckduckgo.app.referral.AppInstallationReferrerStateListener.Companio import com.duckduckgo.app.referral.ParseFailureReason.* import com.duckduckgo.app.referral.ParsedReferrerResult.* import com.duckduckgo.app.statistics.AtbInitializerListener -import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.experiments.api.VariantManager +import com.duckduckgo.experiments.impl.VariantManagerImpl.Companion.RESERVED_EU_AUCTION_VARIANT import dagger.SingleInstanceIn import javax.inject.Inject import kotlinx.coroutines.delay @@ -173,7 +174,7 @@ class PlayStoreAppReferrerStateListener @Inject constructor( appReferrerDataStore.campaignSuffix = result.campaignSuffix } is EuAuctionReferrerFound -> { - variantManager.updateAppReferrerVariant(VariantManager.RESERVED_EU_AUCTION_VARIANT) + variantManager.updateAppReferrerVariant(RESERVED_EU_AUCTION_VARIANT) appReferrerDataStore.installedFromEuAuction = true } else -> {} diff --git a/app/src/test/java/com/duckduckgo/app/about/AboutDuckDuckGoViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/about/AboutDuckDuckGoViewModelTest.kt index 8905755fe210..4feb32876835 100644 --- a/app/src/test/java/com/duckduckgo/app/about/AboutDuckDuckGoViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/about/AboutDuckDuckGoViewModelTest.kt @@ -21,10 +21,10 @@ import app.cash.turbine.test import com.duckduckgo.app.about.AboutDuckDuckGoViewModel.* import com.duckduckgo.app.about.AboutDuckDuckGoViewModel.Companion.MAX_EASTER_EGG_COUNT import com.duckduckgo.app.pixels.AppPixelName -import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.experiments.api.VariantManager import com.duckduckgo.networkprotection.api.NetworkProtectionWaitlist import com.duckduckgo.networkprotection.api.NetworkProtectionWaitlist.NetPWaitlistState.NotUnlocked import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -69,7 +69,7 @@ internal class AboutDuckDuckGoViewModelTest { fun before() { MockitoAnnotations.openMocks(this) - whenever(mockVariantManager.getVariant()).thenReturn(VariantManager.DEFAULT_VARIANT) + whenever(mockVariantManager.getVariantKey()).thenReturn("") whenever(mockAppBuildConfig.versionName).thenReturn("name") whenever(mockAppBuildConfig.versionCode).thenReturn(1) runBlocking { diff --git a/app/src/test/java/com/duckduckgo/app/brokensite/api/BrokenSiteSubmitterTest.kt b/app/src/test/java/com/duckduckgo/app/brokensite/api/BrokenSiteSubmitterTest.kt index 71d931095a99..e0be7bd115d1 100644 --- a/app/src/test/java/com/duckduckgo/app/brokensite/api/BrokenSiteSubmitterTest.kt +++ b/app/src/test/java/com/duckduckgo/app/brokensite/api/BrokenSiteSubmitterTest.kt @@ -6,8 +6,6 @@ import com.duckduckgo.app.brokensite.model.BrokenSite import com.duckduckgo.app.brokensite.model.BrokenSiteCategory import com.duckduckgo.app.pixels.AppPixelName.BROKEN_SITE_REPORT import com.duckduckgo.app.privacy.db.UserAllowListRepository -import com.duckduckgo.app.statistics.Variant -import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.model.Atb import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.store.StatisticsDataStore @@ -15,6 +13,7 @@ import com.duckduckgo.app.trackerdetection.db.TdsMetadataDao import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.brokensite.api.BrokenSiteLastSentReport import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.experiments.api.VariantManager import com.duckduckgo.feature.toggles.api.FeatureToggle import com.duckduckgo.privacy.config.api.ContentBlocking import com.duckduckgo.privacy.config.api.Gpc @@ -81,7 +80,7 @@ class BrokenSiteSubmitterTest { whenever(mockGpc.isEnabled()).thenReturn(true) whenever(mockTdsMetadataDao.eTag()).thenReturn("eTAG") whenever(mockStatisticsDataStore.atb).thenReturn(Atb("v123-456")) - whenever(mockVariantManager.getVariant()).thenReturn(Variant("g", 1.0, emptyList()) { true }) + whenever(mockVariantManager.getVariantKey()).thenReturn("g") whenever(mockPrivacyConfig.privacyConfigData()).thenReturn(PrivacyConfigData(version = "v", eTag = "e")) testee = BrokenSiteSubmitter( diff --git a/app/src/test/java/com/duckduckgo/app/browser/DuckDuckGoRequestRewriterTest.kt b/app/src/test/java/com/duckduckgo/app/browser/DuckDuckGoRequestRewriterTest.kt index 591261e7104d..3c68c199cd5f 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/DuckDuckGoRequestRewriterTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/DuckDuckGoRequestRewriterTest.kt @@ -20,10 +20,10 @@ import android.net.Uri import androidx.core.net.toUri import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.app.referral.AppReferrerDataStore -import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.model.Atb import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.common.utils.AppUrl.ParamKey +import com.duckduckgo.experiments.api.VariantManager import org.junit.Assert.* import org.junit.Before import org.junit.Test @@ -43,9 +43,14 @@ class DuckDuckGoRequestRewriterTest { @Before fun before() { - whenever(mockVariantManager.getVariant()).thenReturn(VariantManager.DEFAULT_VARIANT) + whenever(mockVariantManager.getVariantKey()).thenReturn("") whenever(mockAppReferrerDataStore.installedFromEuAuction).thenReturn(false) - testee = DuckDuckGoRequestRewriter(DuckDuckGoUrlDetectorImpl(), mockStatisticsStore, mockVariantManager, mockAppReferrerDataStore) + testee = DuckDuckGoRequestRewriter( + DuckDuckGoUrlDetectorImpl(), + mockStatisticsStore, + mockVariantManager, + mockAppReferrerDataStore, + ) builder = Uri.Builder() } diff --git a/app/src/test/java/com/duckduckgo/app/browser/QueryUrlConverterTest.kt b/app/src/test/java/com/duckduckgo/app/browser/QueryUrlConverterTest.kt index da88ebd3e81c..b2c532697dbc 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/QueryUrlConverterTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/QueryUrlConverterTest.kt @@ -21,13 +21,12 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.app.browser.omnibar.QueryOrigin import com.duckduckgo.app.browser.omnibar.QueryUrlConverter import com.duckduckgo.app.referral.AppReferrerDataStore -import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.store.StatisticsDataStore +import com.duckduckgo.experiments.api.VariantManager import org.junit.Assert.* import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.whenever @@ -47,7 +46,7 @@ class QueryUrlConverterTest { @Before fun setup() { - whenever(variantManager.getVariant(any())).thenReturn(VariantManager.DEFAULT_VARIANT) + whenever(variantManager.getVariantKey()).thenReturn("") } @Test diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/OnboardingPageManagerPageCountTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/OnboardingPageManagerPageCountTest.kt index b03ad275eef8..ed6288d05299 100644 --- a/app/src/test/java/com/duckduckgo/app/onboarding/ui/OnboardingPageManagerPageCountTest.kt +++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/OnboardingPageManagerPageCountTest.kt @@ -18,7 +18,6 @@ package com.duckduckgo.app.onboarding.ui import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector import com.duckduckgo.app.global.DefaultRoleBrowserDialog -import com.duckduckgo.app.statistics.Variant import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test @@ -58,7 +57,7 @@ class OnboardingPageManagerPageCountTest(private val testCase: TestCase) { companion object { - private val otherVariant = Variant(key = "variant", features = listOf(), filterBy = { true }) + private const val otherVariant = "variant" @JvmStatic @Parameterized.Parameters(name = "Test case: {index} - {0}") @@ -81,6 +80,6 @@ class OnboardingPageManagerPageCountTest(private val testCase: TestCase) { data class TestCase( val defaultBrowserPage: Boolean, val expectedPageCount: Int, - val variant: Variant, + val variantKey: String, ) } diff --git a/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesMultipleReportReferenceTest.kt b/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesMultipleReportReferenceTest.kt index 0f70827dca2a..37d5edd249b8 100644 --- a/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesMultipleReportReferenceTest.kt +++ b/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesMultipleReportReferenceTest.kt @@ -20,8 +20,6 @@ import com.duckduckgo.app.brokensite.BrokenSiteViewModel import com.duckduckgo.app.brokensite.api.BrokenSiteSubmitter import com.duckduckgo.app.brokensite.model.BrokenSite import com.duckduckgo.app.pixels.AppPixelName -import com.duckduckgo.app.statistics.Variant -import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.model.Atb import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.store.StatisticsDataStore @@ -30,6 +28,7 @@ import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.brokensite.api.BrokenSiteLastSentReport import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.FileUtilities +import com.duckduckgo.experiments.api.VariantManager import com.duckduckgo.feature.toggles.api.FeatureToggle import com.duckduckgo.privacy.config.api.Gpc import com.duckduckgo.privacy.config.api.PrivacyConfig @@ -143,7 +142,7 @@ class BrokenSitesMultipleReportReferenceTest(private val testCase: MultipleRepor whenever(mockGpc.isEnabled()).thenReturn(report.gpcEnabled) whenever(mockTdsMetadataDao.eTag()).thenReturn(report.blocklistVersion) whenever(mockStatisticsDataStore.atb).thenReturn(Atb("v123-456")) - whenever(mockVariantManager.getVariant()).thenReturn(Variant("g", 1.0, emptyList()) { true }) + whenever(mockVariantManager.getVariantKey()).thenReturn("g") whenever(mockPrivacyConfig.privacyConfigData()).thenReturn( PrivacyConfigData(version = report.remoteConfigVersion ?: "v", eTag = report.remoteConfigEtag ?: "e"), ) diff --git a/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesReferenceTest.kt b/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesReferenceTest.kt index ad845791e340..70d24b8a6891 100644 --- a/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesReferenceTest.kt +++ b/app/src/test/java/com/duckduckgo/app/referencetests/brokensites/BrokenSitesReferenceTest.kt @@ -22,8 +22,6 @@ import com.duckduckgo.app.brokensite.api.BrokenSiteSubmitter import com.duckduckgo.app.brokensite.model.BrokenSite import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.privacy.db.UserAllowListRepository -import com.duckduckgo.app.statistics.Variant -import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.model.Atb import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.store.StatisticsDataStore @@ -31,6 +29,7 @@ import com.duckduckgo.app.trackerdetection.db.TdsMetadataDao import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.FileUtilities +import com.duckduckgo.experiments.api.VariantManager import com.duckduckgo.feature.toggles.api.FeatureToggle import com.duckduckgo.privacy.config.api.Gpc import com.duckduckgo.privacy.config.api.PrivacyConfig @@ -133,7 +132,7 @@ class BrokenSitesReferenceTest(private val testCase: TestCase) { whenever(mockGpc.isEnabled()).thenReturn(testCase.gpcEnabled) whenever(mockTdsMetadataDao.eTag()).thenReturn(testCase.blocklistVersion) whenever(mockStatisticsDataStore.atb).thenReturn(Atb("v123-456")) - whenever(mockVariantManager.getVariant()).thenReturn(Variant("g", 1.0, emptyList()) { true }) + whenever(mockVariantManager.getVariantKey()).thenReturn("g") whenever(mockPrivacyConfig.privacyConfigData()).thenReturn( PrivacyConfigData(version = testCase.remoteConfigVersion ?: "v", eTag = testCase.remoteConfigEtag ?: "e"), ) diff --git a/app/src/test/java/com/duckduckgo/app/statistics/AtbInitializerTest.kt b/app/src/test/java/com/duckduckgo/app/statistics/AtbInitializerTest.kt index 34021f512f68..ae605047e289 100644 --- a/app/src/test/java/com/duckduckgo/app/statistics/AtbInitializerTest.kt +++ b/app/src/test/java/com/duckduckgo/app/statistics/AtbInitializerTest.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -41,17 +42,9 @@ class AtbInitializerTest { @Test fun whenReferrerInformationInstantlyAvailableThenAtbInitialized() = runTest { - whenever(statisticsDataStore.hasInstallationStatistics).thenReturn(false) - appReferrerStateListener = StubAppReferrerFoundStateListener(referrer = "xx") - testee = AtbInitializer( - coroutineRule.testScope, - statisticsDataStore, - statisticsUpdater, - setOf(appReferrerStateListener), - coroutineRule.testDispatcherProvider, - ) + configureNeverInitialized() - testee.initialize() + testee.onPrivacyConfigDownloaded() verify(statisticsUpdater).initializeAtb() } @@ -68,13 +61,13 @@ class AtbInitializerTest { coroutineRule.testDispatcherProvider, ) - testee.initialize() + testee.onPrivacyConfigDownloaded() verify(statisticsUpdater).initializeAtb() } @Test - fun whenReferrerInformationTimesOutThenAtbInitialized() = runTest { + fun whenReferrerInformationTimesOutThenRefreshAtbNotCalled() = runTest { whenever(statisticsDataStore.hasInstallationStatistics).thenReturn(false) appReferrerStateListener = StubAppReferrerFoundStateListener(referrer = "xx", mockDelayMs = Long.MAX_VALUE) testee = AtbInitializer( @@ -87,12 +80,39 @@ class AtbInitializerTest { testee.initialize() - verify(statisticsUpdater).initializeAtb() + verify(statisticsUpdater, never()).initializeAtb() } @Test fun whenAlreadyInitializedThenRefreshCalled() = runTest { configureAlreadyInitialized() + + testee.initialize() + + verify(statisticsUpdater).refreshAppRetentionAtb() + } + + @Test + fun givenHasInstallationStatisticsWhenOnPrivacyConfigDownloadedThenAtbInitializedNeverCalled() = runTest { + configureAlreadyInitialized() + + testee.onPrivacyConfigDownloaded() + + verify(statisticsUpdater, never()).initializeAtb() + } + + @Test + fun givenNeverInstallationStatisticsWhenOnPrivacyConfigDownloadedThenAtbInitialized() = runTest { + configureNeverInitialized() + + testee.onPrivacyConfigDownloaded() + + verify(statisticsUpdater).initializeAtb() + } + + private fun configureNeverInitialized() { + whenever(statisticsDataStore.hasInstallationStatistics).thenReturn(false) + appReferrerStateListener = StubAppReferrerFoundStateListener(referrer = "xx") testee = AtbInitializer( coroutineRule.testScope, statisticsDataStore, @@ -100,13 +120,17 @@ class AtbInitializerTest { setOf(appReferrerStateListener), coroutineRule.testDispatcherProvider, ) - - testee.initialize() - verify(statisticsUpdater).refreshAppRetentionAtb() } private fun configureAlreadyInitialized() { whenever(statisticsDataStore.hasInstallationStatistics).thenReturn(true) appReferrerStateListener = StubAppReferrerFoundStateListener(referrer = "xx") + testee = AtbInitializer( + coroutineRule.testScope, + statisticsDataStore, + statisticsUpdater, + setOf(appReferrerStateListener), + coroutineRule.testDispatcherProvider, + ) } } diff --git a/app/src/test/java/com/duckduckgo/app/statistics/ExperimentationVariantManagerTest.kt b/app/src/test/java/com/duckduckgo/app/statistics/ExperimentationVariantManagerTest.kt deleted file mode 100644 index 1665235125e1..000000000000 --- a/app/src/test/java/com/duckduckgo/app/statistics/ExperimentationVariantManagerTest.kt +++ /dev/null @@ -1,202 +0,0 @@ -/* - * Copyright (c) 2018 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. - */ - -@file:Suppress("SameParameterValue") - -package com.duckduckgo.app.statistics - -import com.duckduckgo.app.statistics.store.StatisticsDataStore -import com.duckduckgo.appbuildconfig.api.AppBuildConfig -import org.junit.Assert.* -import org.junit.Before -import org.junit.Test -import org.mockito.kotlin.* - -class ExperimentationVariantManagerTest { - - private lateinit var testee: ExperimentationVariantManager - - private val mockStore: StatisticsDataStore = mock() - private val mockRandomizer: IndexRandomizer = mock() - private val appBuildConfig: AppBuildConfig = mock() - private val activeVariants = mutableListOf() - - @Before - fun setup() { - // mock randomizer always returns the first active variant - whenever(mockRandomizer.random(any())).thenReturn(0) - - testee = ExperimentationVariantManager(mockStore, mockRandomizer, appBuildConfig) - } - - @Test - fun whenVariantAlreadyPersistedThenVariantReturned() { - activeVariants.add(Variant("foo", 100.0, filterBy = { true })) - whenever(mockStore.variant).thenReturn("foo") - - assertEquals("foo", testee.getVariant(activeVariants).key) - } - - @Test - fun whenVariantAlreadyPersistedThenVariantAllocatorNeverInvoked() { - activeVariants.add(Variant("foo", 100.0, filterBy = { true })) - whenever(mockStore.variant).thenReturn("foo") - - testee.getVariant(activeVariants) - verify(mockRandomizer, never()).random(any()) - } - - @Test - fun whenNoVariantsAvailableThenDefaultVariantHasEmptyStringForKey() { - whenever(mockStore.variant).thenReturn("foo") - - val defaultVariant = testee.getVariant(activeVariants) - assertEquals("", defaultVariant.key) - assertTrue(defaultVariant.features.isEmpty()) - } - - @Test - fun whenNoVariantsAvailableThenDefaultVariantHasNoExperimentalFeaturesEnabled() { - whenever(mockStore.variant).thenReturn("foo") - - val defaultVariant = testee.getVariant(activeVariants) - assertTrue(defaultVariant.features.isEmpty()) - } - - @Test - fun whenVariantPersistedIsNotFoundInActiveVariantListThenRestoredToDefaultVariant() { - activeVariants.add(Variant("foo", 100.0, filterBy = { true })) - whenever(mockStore.variant).thenReturn("bar") - - assertEquals(VariantManager.DEFAULT_VARIANT, testee.getVariant(activeVariants)) - } - - @Test - fun whenVariantPersistedIsNotFoundInActiveVariantListThenNewVariantIsPersisted() { - activeVariants.add(Variant("foo", 100.0, filterBy = { true })) - - whenever(mockStore.variant).thenReturn("bar") - testee.getVariant(activeVariants) - - verify(mockStore).variant = VariantManager.DEFAULT_VARIANT.key - } - - @Test - fun whenNoVariantPersistedThenNewVariantAllocated() { - activeVariants.add(Variant("foo", 100.0, filterBy = { true })) - - testee.getVariant(activeVariants) - - verify(mockRandomizer).random(any()) - } - - @Test - fun whenNoVariantPersistedThenNewVariantKeyIsAllocatedAndPersisted() { - activeVariants.add(Variant("foo", 100.0, filterBy = { true })) - - testee.getVariant(activeVariants) - - verify(mockStore).variant = "foo" - } - - @Test - fun whenVariantDoesNotComplyWithFiltersThenDefaultVariantIsPersisted() { - activeVariants.add(Variant("foo", 100.0, filterBy = { false })) - - testee.getVariant(activeVariants) - - verify(mockStore).variant = VariantManager.DEFAULT_VARIANT.key - } - - @Test - fun whenVariantDoesNotComplyWithFiltersUsingAppBuildConfigThenDefaultVariantIsPersisted() { - whenever(appBuildConfig.sdkInt).thenReturn(10) - activeVariants.add(Variant("foo", 100.0, filterBy = { config -> config.sdkInt == 11 })) - - testee.getVariant(activeVariants) - - verify(mockStore).variant = VariantManager.DEFAULT_VARIANT.key - } - - @Test - fun whenVariantDoesComplyWithFiltersUsingAppBuildConfigThenDefaultVariantIsPersisted() { - whenever(appBuildConfig.sdkInt).thenReturn(10) - activeVariants.add(Variant("foo", 100.0, filterBy = { config -> config.sdkInt == 10 })) - - testee.getVariant(activeVariants) - - verify(mockStore).variant = "foo" - } - - @Test - fun whenVariantDoesComplyWithFiltersThenNewVariantKeyIsAllocatedAndPersisted() { - activeVariants.add(Variant("foo", 100.0, filterBy = { true })) - - testee.getVariant(activeVariants) - - verify(mockStore).variant = "foo" - } - - @Test - fun whenReferrerVariantSetWithNoActiveVariantsThenReferrerVariantReturned() { - val referrerVariantKey = "xx" - mockUpdateScenario(referrerVariantKey) - - val variant = testee.getVariant(emptyList()) - assertEquals(referrerVariantKey, variant.key) - } - - @Test - fun whenReferrerVariantSetWithActiveVariantsThenReferrerVariantReturned() { - val referrerVariantKey = "xx" - mockUpdateScenario(referrerVariantKey) - - activeVariants.add(Variant("foo", 100.0, filterBy = { true })) - activeVariants.add(Variant("bar", 100.0, filterBy = { true })) - val variant = testee.getVariant(activeVariants) - - assertEquals(referrerVariantKey, variant.key) - } - - @Test - fun whenUpdatingReferrerVariantThenDataStoreHasItsDataUpdated() { - testee.updateAppReferrerVariant("xx") - verify(mockStore).referrerVariant = "xx" - verify(mockStore).variant = "xx" - } - - @Test - fun whenUpdatingReferrerVariantThenNewReferrerVariantReturned() { - val originalVariant = testee.getVariant(activeVariants) - mockUpdateScenario("xx") - val newVariant = testee.getVariant(activeVariants) - assertNotEquals(originalVariant, newVariant) - assertEquals("xx", newVariant.key) - } - - @Test - fun whenUnknownReferrerVariantReturnedThenNoFeaturesEnabled() { - mockUpdateScenario("xx") - val variant = testee.getVariant(activeVariants) - assertTrue(variant.features.isEmpty()) - } - - private fun mockUpdateScenario(key: String) { - testee.updateAppReferrerVariant(key) - whenever(mockStore.referrerVariant).thenReturn(key) - whenever(mockStore.variant).thenReturn(key) - } -} diff --git a/app/src/test/java/com/duckduckgo/app/statistics/api/StatisticsRequesterTest.kt b/app/src/test/java/com/duckduckgo/app/statistics/api/StatisticsRequesterTest.kt index bddc79c7a364..1581106912fc 100644 --- a/app/src/test/java/com/duckduckgo/app/statistics/api/StatisticsRequesterTest.kt +++ b/app/src/test/java/com/duckduckgo/app/statistics/api/StatisticsRequesterTest.kt @@ -16,20 +16,28 @@ package com.duckduckgo.app.statistics.api -import com.duckduckgo.app.statistics.Variant -import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.model.Atb import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.autofill.api.email.EmailManager +import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.InstantSchedulersRule import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.experiments.api.VariantManager import io.reactivex.Observable +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope import okhttp3.ResponseBody import org.junit.Before import org.junit.Rule import org.junit.Test -import org.mockito.kotlin.* - +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@ExperimentalCoroutinesApi class StatisticsRequesterTest { private var mockStatisticsStore: StatisticsDataStore = mock() @@ -43,15 +51,26 @@ class StatisticsRequesterTest { return listOf() } } - private var testee: StatisticsRequester = StatisticsRequester(mockStatisticsStore, mockService, mockVariantManager, plugins, mockEmailManager) @get:Rule - @Suppress("unused") val schedulers = InstantSchedulersRule() + @get:Rule + val coroutineTestRule: CoroutineTestRule = CoroutineTestRule() + + private var testee: StatisticsRequester = StatisticsRequester( + mockStatisticsStore, + mockService, + mockVariantManager, + plugins, + mockEmailManager, + TestScope(), + coroutineTestRule.testDispatcherProvider, + ) + @Before fun before() { - whenever(mockVariantManager.getVariant()).thenReturn(Variant("ma", 100.0, filterBy = { true })) + whenever(mockVariantManager.getVariantKey()).thenReturn("ma") whenever(mockService.atb(any(), any())).thenReturn(Observable.just(ATB)) whenever(mockService.updateSearchAtb(any(), any(), any(), any())).thenReturn(Observable.just(Atb(NEW_ATB))) whenever(mockService.exti(any(), any())).thenReturn(Observable.just(mockResponseBody)) @@ -137,6 +156,7 @@ class StatisticsRequesterTest { @Test fun whenAlreadyInitializedWithLegacyAtbThenInitializationRemovesLegacyVariant() { configureStoredStatistics() + whenever(mockVariantManager.defaultVariantKey()).thenReturn("") whenever(mockStatisticsStore.atb).thenReturn(Atb("v123ma")) testee.initializeAtb() verify(mockStatisticsStore).atb = Atb("v123") diff --git a/build.gradle b/build.gradle index b6d6d3d4e475..9458176ebbda 100644 --- a/build.gradle +++ b/build.gradle @@ -139,8 +139,12 @@ subprojects { && dependencyPath != ":feature-toggles-api" && dependencyPath != ":navigation-api" ) { - throw new GradleException("Invalid dependency $projectPath -> $dependencyPath. " + - "'api' modules can't depend on other 'api' modules") + if (projectPath.endsWith(":feature-toggles-api") && dependencyPath == ":experiments-api") { + // noop + } else { + throw new GradleException("Invalid dependency $projectPath -> $dependencyPath. " + + "'api' modules can't depend on other 'api' modules") + } } if (dependencyPath.endsWith(":app")) { throw new GradleException("Invalid dependency $projectPath -> $dependencyPath. " + diff --git a/experiments/experiments-api/.gitignore b/experiments/experiments-api/.gitignore new file mode 100644 index 000000000000..42afabfd2abe --- /dev/null +++ b/experiments/experiments-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/experiments/experiments-api/build.gradle b/experiments/experiments-api/build.gradle new file mode 100644 index 000000000000..2349408df135 --- /dev/null +++ b/experiments/experiments-api/build.gradle @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 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. + */ + +plugins { + id 'java-library' + id 'kotlin' +} + +apply from: "$rootProject.projectDir/code-formatting.gradle" + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + implementation Kotlin.stdlib.jdk7 + +} + diff --git a/experiments/experiments-api/src/main/java/com/duckduckgo/experiments/api/VariantConfig.kt b/experiments/experiments-api/src/main/java/com/duckduckgo/experiments/api/VariantConfig.kt new file mode 100644 index 000000000000..afac678144d4 --- /dev/null +++ b/experiments/experiments-api/src/main/java/com/duckduckgo/experiments/api/VariantConfig.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 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.api + +data class VariantConfig( + val variantKey: String, + val weight: Double? = 0.0, + val filters: VariantFilters? = null, +) + +data class VariantFilters( + val locale: List? = null, +) diff --git a/experiments/experiments-api/src/main/java/com/duckduckgo/experiments/api/VariantManager.kt b/experiments/experiments-api/src/main/java/com/duckduckgo/experiments/api/VariantManager.kt new file mode 100644 index 000000000000..3565123f3a87 --- /dev/null +++ b/experiments/experiments-api/src/main/java/com/duckduckgo/experiments/api/VariantManager.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023 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.api + +/** Public interface for variant manager feature*/ +interface VariantManager { + /** + * Returns the default variant key + */ + fun defaultVariantKey(): String + + /** + * Returns the variant key assigned to the user + */ + fun getVariantKey(): String? + + /** + * Updates user experimental variant when referralResultReceived from PlayStore + */ + fun updateAppReferrerVariant(variant: String) + + /** + * Persists experimental variants in the local database + * + * @param variants Updated list of VariantConfig objects + */ + fun saveVariants(variants: List) +} diff --git a/experiments/experiments-impl/.gitignore b/experiments/experiments-impl/.gitignore new file mode 100644 index 000000000000..42afabfd2abe --- /dev/null +++ b/experiments/experiments-impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/experiments/experiments-impl/build.gradle b/experiments/experiments-impl/build.gradle new file mode 100644 index 000000000000..4533de361269 --- /dev/null +++ b/experiments/experiments-impl/build.gradle @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2021 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. + */ + +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'com.squareup.anvil' + id 'com.google.devtools.ksp' version "$ksp_version" +} + +apply from: "$rootProject.projectDir/gradle/android-library.gradle" + +dependencies { + anvil project(path: ':anvil-compiler') + implementation project(path: ':anvil-annotations') + + implementation project(path: ':di') + implementation project(path: ':common-utils') + implementation project(path: ':experiments-api') + implementation project(path: ':statistics') + implementation project(path: ':app-build-config-api') + + // Room + implementation AndroidX.room.runtime + implementation AndroidX.room.rxJava2 + implementation AndroidX.room.ktx + ksp AndroidX.room.compiler + + // Apache commons + implementation "org.apache.commons:commons-math3:_" + + implementation Kotlin.stdlib.jdk7 + implementation AndroidX.appCompat + implementation JakeWharton.timber + + implementation KotlinX.coroutines.core + + implementation Google.dagger + + implementation Square.retrofit2.converter.moshi + + testImplementation (KotlinX.coroutines.test) { + // https://github.com/Kotlin/kotlinx.coroutines/issues/2023 + // conflicts with mockito due to direct inclusion of byte buddy + exclude group: "org.jetbrains.kotlinx", module: "kotlinx-coroutines-debug" + } + + // Testing dependencies + testImplementation project(path: ':common-test') + testImplementation AndroidX.work.testing + testImplementation AndroidX.room.testing + testImplementation "org.mockito.kotlin:mockito-kotlin:_" + testImplementation Testing.junit4 + testImplementation AndroidX.archCore.testing + testImplementation AndroidX.core + testImplementation AndroidX.test.ext.junit + testImplementation "androidx.test:runner:_" + testImplementation Testing.robolectric + testImplementation CashApp.turbine + testImplementation "org.threeten:threetenbp:_" + + androidTestImplementation project(path: ':common-test') + testImplementation project(path: ':common-test') +} + +android { + anvil { + generateDaggerFactories = true // default is false + } + testOptions { + unitTests { + includeAndroidResources = true + } + } + namespace 'com.duckduckgo.experiments.impl' +} + diff --git a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/ExperimentVariantRepository.kt b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/ExperimentVariantRepository.kt new file mode 100644 index 000000000000..a7608fc88862 --- /dev/null +++ b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/ExperimentVariantRepository.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2023 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 + +import com.duckduckgo.app.statistics.store.StatisticsDataStore +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.experiments.api.VariantConfig +import com.duckduckgo.experiments.impl.store.ExperimentVariantDao +import com.duckduckgo.experiments.impl.store.ExperimentVariantEntity +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import timber.log.Timber + +interface ExperimentVariantRepository { + fun saveVariants(variants: List) + fun getActiveVariants(): List + fun getUserVariant(): String? + fun updateVariant(variantKey: String) + fun getAppReferrerVariant(): String? + fun updateAppReferrerVariant(variant: String) +} + +@ContributesBinding(AppScope::class) +class ExperimentVariantRepositoryImpl @Inject constructor( + private val experimentVariantDao: ExperimentVariantDao, + private val store: StatisticsDataStore, +) : ExperimentVariantRepository { + + override fun saveVariants(variants: List) { + val variantEntityList: MutableList = mutableListOf() + variants.map { + variantEntityList.add( + ExperimentVariantEntity( + key = it.variantKey, + weight = it.weight, + localeFilter = it.filters?.locale.orEmpty(), + ), + ) + } + experimentVariantDao.delete() + experimentVariantDao.insertAll(variantEntityList) + } + + override fun getActiveVariants(): List { + return experimentVariantDao.variants() + } + + override fun getUserVariant(): String? = store.variant + + override fun updateVariant(variantKey: String) { + Timber.i("Updating variant for user: $variantKey") + store.variant = variantKey + } + + override fun getAppReferrerVariant(): String? = store.referrerVariant + + override fun updateAppReferrerVariant(variant: String) { + Timber.i("Updating variant for app referer: $variant") + store.variant = variant + store.referrerVariant = variant + } +} diff --git a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/Variant.kt b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/Variant.kt new file mode 100644 index 000000000000..23662b90f63e --- /dev/null +++ b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/Variant.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 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 + +import com.duckduckgo.appbuildconfig.api.AppBuildConfig + +/** + * A variant which can be used for experimentation. + * @param weight Relative weight. These are normalised to all other variants, so they don't have to add up to any specific number. + * + */ +data class Variant( + val key: String, + override val weight: Double = 0.0, + val filterBy: (config: AppBuildConfig) -> Boolean, +) : Probabilistic diff --git a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/VariantManagerImpl.kt b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/VariantManagerImpl.kt new file mode 100644 index 000000000000..52f04042f11d --- /dev/null +++ b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/VariantManagerImpl.kt @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2023 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 + +import androidx.annotation.WorkerThread +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.experiments.api.VariantConfig +import com.duckduckgo.experiments.api.VariantManager +import com.duckduckgo.experiments.impl.store.ExperimentVariantEntity +import com.squareup.anvil.annotations.ContributesBinding +import java.util.Locale +import javax.inject.Inject +import timber.log.Timber + +@WorkerThread +@ContributesBinding(AppScope::class) +class VariantManagerImpl @Inject constructor( + private val indexRandomizer: IndexRandomizer, + private val appBuildConfig: AppBuildConfig, + private val experimentVariantRepository: ExperimentVariantRepository, +) : VariantManager { + + override fun defaultVariantKey(): String { + return DEFAULT_VARIANT.key + } + + override fun getVariantKey(): String? { + return experimentVariantRepository.getUserVariant() + } + + override fun updateAppReferrerVariant(variant: String) { + experimentVariantRepository.updateAppReferrerVariant(variant) + } + + override fun saveVariants(variants: List) { + experimentVariantRepository.saveVariants(variants) + Timber.d("Variants update ${experimentVariantRepository.getActiveVariants()}") + + val activeVariants = convertEntitiesToVariants(experimentVariantRepository.getActiveVariants()) + val currentVariantKey = experimentVariantRepository.getUserVariant() + + updateUserVariant(activeVariants, currentVariantKey) + } + + private fun updateUserVariant(activeVariants: List, currentVariantKey: String?) { + if (currentVariantKey == DEFAULT_VARIANT.key) { + return + } + + if (currentVariantKey != null && matchesReferrerVariant(currentVariantKey)) { + return + } + + if (currentVariantKey == null) { + allocateNewVariant(activeVariants) + return + } + + val keyInActiveVariants = activeVariants.map { it.key }.contains(currentVariantKey) + if (!keyInActiveVariants) { + Timber.i("Variant $currentVariantKey no longer an active variant; user will now use default variant") + val newVariant = DEFAULT_VARIANT + experimentVariantRepository.updateVariant(newVariant.key) + return + } + + Timber.i("Variant $currentVariantKey is still in use, no need to update") + } + + private fun convertEntitiesToVariants(activeVariantEntities: List): List { + val activeVariants: MutableList = mutableListOf() + activeVariantEntities.map { entity -> + activeVariants.add( + Variant( + key = entity.key, + weight = entity.weight ?: 0.0, + filterBy = addFilters(entity), + ), + ) + } + return activeVariants + } + + private fun addFilters(entity: ExperimentVariantEntity): (AppBuildConfig) -> Boolean { + if (entity.key == "sc" || entity.key == "se") { + return { isSerpRegionToggleCountry() } + } + if (entity.localeFilter.isEmpty()) { + return { noFilter() } + } + + val userLocale = Locale.getDefault() + return { entity.localeFilter.contains(userLocale.toString()) } + } + + private fun matchesReferrerVariant(key: String): Boolean { + return key == experimentVariantRepository.getAppReferrerVariant() + } + + private fun allocateNewVariant(activeVariants: List): Variant { + var newVariant = generateVariant(activeVariants) + val compliesWithFilters = newVariant.filterBy(appBuildConfig) + + if (!compliesWithFilters || appBuildConfig.isDefaultVariantForced) { + newVariant = DEFAULT_VARIANT + } + Timber.i("Current variant is null; allocating new one $newVariant") + experimentVariantRepository.updateVariant(newVariant.key) + return newVariant + } + + private fun generateVariant(activeVariants: List): Variant { + val weightSum = activeVariants.sumByDouble { it.weight } + if (weightSum == 0.0) { + Timber.v("No variants active; allocating default") + return DEFAULT_VARIANT + } + val randomizedIndex = indexRandomizer.random(activeVariants) + return activeVariants[randomizedIndex] + } + + companion object { + + const val RESERVED_EU_AUCTION_VARIANT = "ml" + + // this will be returned when there are no other active experiments + private val DEFAULT_VARIANT = Variant(key = "", filterBy = { noFilter() }) + + private val serpRegionToggleTargetCountries = listOf( + "AU", + "AT", + "DK", + "FI", + "FR", + "DE", + "IT", + "IE", + "NZ", + "NO", + "ES", + "SE", + "GB", + ) + + private fun noFilter(): Boolean = true + + private fun isSerpRegionToggleCountry(): Boolean { + val locale = Locale.getDefault() + return serpRegionToggleTargetCountries.contains(locale.country) + } + } +} diff --git a/statistics/src/main/java/com/duckduckgo/app/statistics/WeightedRandomizer.kt b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/WeightedRandomizer.kt similarity index 82% rename from statistics/src/main/java/com/duckduckgo/app/statistics/WeightedRandomizer.kt rename to experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/WeightedRandomizer.kt index a5a8bb9241fc..1ae8a31b798b 100644 --- a/statistics/src/main/java/com/duckduckgo/app/statistics/WeightedRandomizer.kt +++ b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/WeightedRandomizer.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018 DuckDuckGo + * Copyright (c) 2023 DuckDuckGo * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,8 +14,11 @@ * limitations under the License. */ -package com.duckduckgo.app.statistics +package com.duckduckgo.experiments.impl +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject import org.apache.commons.math3.distribution.EnumeratedIntegerDistribution interface IndexRandomizer { @@ -26,7 +29,8 @@ interface Probabilistic { val weight: Double } -class WeightedRandomizer : IndexRandomizer { +@ContributesBinding(AppScope::class) +class WeightedRandomizer @Inject constructor() : IndexRandomizer { override fun random(items: List): Int { val indexArray = arrayPopulatedWithIndexes(items) diff --git a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/di/ExperimentsModule.kt b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/di/ExperimentsModule.kt new file mode 100644 index 000000000000..43f5021ff1fd --- /dev/null +++ b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/di/ExperimentsModule.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2023 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.di + +import android.content.Context +import androidx.room.Room +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.experiments.impl.store.ExperimentVariantDao +import com.duckduckgo.experiments.impl.store.ExperimentsDatabase +import com.duckduckgo.experiments.impl.store.ExperimentsDatabase.Companion.ALL_MIGRATIONS +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import dagger.SingleInstanceIn + +@Module +@ContributesTo(AppScope::class) +object ExperimentsModule { + + @Provides + @SingleInstanceIn(AppScope::class) + fun providesExperimentsDatabase(context: Context): ExperimentsDatabase { + return Room.databaseBuilder(context, ExperimentsDatabase::class.java, "experiments.db") + .addMigrations(*ALL_MIGRATIONS) + .fallbackToDestructiveMigration() + .build() + } + + @Provides + @SingleInstanceIn(AppScope::class) + fun providesExperimentVariantDao(experimentsDatabase: ExperimentsDatabase): ExperimentVariantDao { + return experimentsDatabase.experimentVariantsDao() + } +} diff --git a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/store/ExperimentVariantDao.kt b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/store/ExperimentVariantDao.kt new file mode 100644 index 000000000000..245ecbc7e945 --- /dev/null +++ b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/store/ExperimentVariantDao.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 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.store + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +abstract class ExperimentVariantDao { + + @Query("select * from experiment_variants") + abstract fun variants(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract fun insertAll(variants: List) + + @Query("delete from experiment_variants") + abstract fun delete() +} diff --git a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/store/ExperimentsDatabase.kt b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/store/ExperimentsDatabase.kt new file mode 100644 index 000000000000..f2886fed1e1b --- /dev/null +++ b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/store/ExperimentsDatabase.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2023 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.store + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import androidx.room.migration.Migration + +@Database( + exportSchema = true, + version = 1, + entities = [ + ExperimentVariantEntity::class, + ], +) + +@TypeConverters( + StringListConverter::class, +) + +abstract class ExperimentsDatabase : RoomDatabase() { + abstract fun experimentVariantsDao(): ExperimentVariantDao + + companion object { + val ALL_MIGRATIONS = emptyArray() + } +} diff --git a/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/store/VariantManagerEntity.kt b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/store/VariantManagerEntity.kt new file mode 100644 index 000000000000..2a7a11256bfe --- /dev/null +++ b/experiments/experiments-impl/src/main/java/com/duckduckgo/experiments/impl/store/VariantManagerEntity.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2023 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.store + +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverter +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types + +@Entity(tableName = "experiment_variants") +data class ExperimentVariantEntity( + @PrimaryKey val key: String, + val weight: Double?, + val localeFilter: List = emptyList(), +) + +class StringListConverter { + + @TypeConverter + fun toStringList(value: String): List { + return Adapters.stringListAdapter.fromJson(value)!! + } + + @TypeConverter + fun fromStringList(value: List): String { + return Adapters.stringListAdapter.toJson(value) + } +} + +class Adapters { + companion object { + private val moshi = Moshi.Builder().build() + private val stringListType = Types.newParameterizedType(List::class.java, String::class.java) + val stringListAdapter: JsonAdapter> = moshi.adapter(stringListType) + } +} diff --git a/experiments/experiments-impl/src/test/java/com/duckduckgo/experiments/impl/ExperimentationVariantManagerTest.kt b/experiments/experiments-impl/src/test/java/com/duckduckgo/experiments/impl/ExperimentationVariantManagerTest.kt new file mode 100644 index 000000000000..592e1f1a6364 --- /dev/null +++ b/experiments/experiments-impl/src/test/java/com/duckduckgo/experiments/impl/ExperimentationVariantManagerTest.kt @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2023 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 + +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.experiments.api.VariantConfig +import com.duckduckgo.experiments.impl.store.ExperimentVariantEntity +import java.util.Locale +import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class ExperimentationVariantManagerTest { + + private lateinit var testee: VariantManagerImpl + + private val mockRandomizer: IndexRandomizer = mock() + private val appBuildConfig: AppBuildConfig = mock() + private val activeVariants = mutableListOf() + private val mockExperimentVariantRepository: ExperimentVariantRepository = mock() + + @Before + fun setup() { + // mock randomizer always returns the first active variant + whenever(mockRandomizer.random(any())).thenReturn(0) + + testee = VariantManagerImpl( + mockRandomizer, + appBuildConfig, + mockExperimentVariantRepository, + ) + } + + @Test + fun whenVariantAlreadyPersistedThenVariantReturned() { + whenever(mockExperimentVariantRepository.getUserVariant()).thenReturn("variantKey") + + assertEquals("variantKey", testee.getVariantKey()) + } + + @Test + fun whenVariantNeverPersistedThenNullReturned() { + whenever(mockExperimentVariantRepository.getUserVariant()).thenReturn(null) + + assertEquals(null, testee.getVariantKey()) + } + + @Test + fun whenGetVariantsKeyThenVariantIsNeverUpdated() { + testee.getVariantKey() + + verify(mockExperimentVariantRepository, never()).updateVariant(any()) + } + + @Test + fun whenVariantsConfigUpdatedThenSaveNewVariantsConfig() { + val variantsConfig = listOf(VariantConfig("variant1", 1.0), VariantConfig("variant2", 1.0)) + + testee.saveVariants(variantsConfig) + + verify(mockExperimentVariantRepository).saveVariants(variantsConfig) + } + + // val variantsConfig = listOf(VariantConfig("variant", 1.0)) + // testee.saveVariants(variantsConfig) + // Old + @Test + fun whenVariantAlreadyPersistedThenVariantAllocatorNeverInvoked() { + val variantsConfig = listOf(VariantConfig("variantKey", 1.0)) + whenever(mockExperimentVariantRepository.getUserVariant()).thenReturn("variantKey") + testee.saveVariants(variantsConfig) + + verify(mockRandomizer, never()).random(any()) + } + + @Test + fun givenVariantsUpdateWhenVariantNeverPersistedThenVariantAllocatorNeverInvoked() { + val variantsConfig = listOf(VariantConfig("variantKey", 1.0)) + val testVariantEntity = ExperimentVariantEntity("variantKey", 1.0) + whenever(mockExperimentVariantRepository.getActiveVariants()).thenReturn(listOf(testVariantEntity)) + whenever(mockExperimentVariantRepository.getUserVariant()).thenReturn(null) + testee.saveVariants(variantsConfig) + + verify(mockRandomizer).random(any()) + } + + @Test + fun whenNoVariantsAvailableThenDefaultVariantIsAssigned() { + whenever(mockExperimentVariantRepository.getActiveVariants()).thenReturn(emptyList()) + whenever(mockExperimentVariantRepository.getUserVariant()).thenReturn(null) + testee.saveVariants(emptyList()) + + verify(mockExperimentVariantRepository).updateVariant("") + } + + @Test + fun whenVariantPersistedIsNotFoundInActiveVariantListThenRestoredToDefaultVariant() { + whenever(mockExperimentVariantRepository.getActiveVariants()).thenReturn(emptyList()) + whenever(mockExperimentVariantRepository.getUserVariant()).thenReturn("variantKey") + testee.saveVariants(emptyList()) + + verify(mockExperimentVariantRepository).updateVariant("") + } + + @Test + fun whenVariantPersistedHasWeightEqualToZeroInActiveVariantListThenVariantIsNotRestored() { + val variantsConfig = listOf(VariantConfig("variantKey", 0.0)) + val testVariantEntity = ExperimentVariantEntity("variantKey", 0.0) + whenever(mockExperimentVariantRepository.getActiveVariants()).thenReturn(listOf(testVariantEntity)) + whenever(mockExperimentVariantRepository.getUserVariant()).thenReturn("variantKey") + testee.saveVariants(variantsConfig) + + verify(mockExperimentVariantRepository, never()).updateVariant(any()) + } + + @Test + fun whenNoVariantPersistedThenNewVariantAllocated() { + addActiveVariantToConfig() + + testee.getVariantKey() + verify(mockRandomizer).random(any()) + } + + @Test + fun whenNoVariantPersistedThenNewVariantKeyIsAllocatedAndPersisted() { + addActiveVariantToConfig() + + testee.getVariantKey() + + verify(mockExperimentVariantRepository).updateVariant("foo") + } + + @Test + fun whenVariantDoesNotComplyWithFiltersThenDefaultVariantIsPersisted() { + val locale = Locale("en", "US") + Locale.setDefault(locale) + addActiveVariantToConfig(localeFilter = listOf("de_DE")) + + testee.getVariantKey() + + verify(mockExperimentVariantRepository).updateVariant("") + } + + @Test + fun whenVariantDoesComplyWithFiltersThenNewVariantKeyIsAllocatedAndPersisted() { + val locale = Locale("en", "US") + Locale.setDefault(locale) + addActiveVariantToConfig(localeFilter = listOf("en_US")) + + testee.getVariantKey() + + verify(mockExperimentVariantRepository).updateVariant("foo") + } + + @Test + fun whenReferrerVariantSetWithNoActiveVariantsThenReferrerVariantReturned() { + val referrerVariantKey = "xx" + mockUpdateScenario(referrerVariantKey) + + val variantKey = testee.getVariantKey() + assertEquals(referrerVariantKey, variantKey) + } + + @Test + fun whenReferrerVariantSetWithActiveVariantsThenReferrerVariantReturned() { + val referrerVariantKey = "xx" + mockUpdateScenario(referrerVariantKey) + + activeVariants.add(Variant("foo", 100.0, filterBy = { true })) + activeVariants.add(Variant("bar", 100.0, filterBy = { true })) + val variantKey = testee.getVariantKey() + + assertEquals(referrerVariantKey, variantKey) + } + + @Test + fun whenUpdatingReferrerVariantThenDataStoreHasItsDataUpdated() { + testee.updateAppReferrerVariant("xx") + + verify(mockExperimentVariantRepository).updateAppReferrerVariant("xx") + } + + @Test + fun whenUpdatingReferrerVariantThenNewReferrerVariantReturned() { + val originalVariant = testee.getVariantKey() + mockUpdateScenario("xx") + val newVariant = testee.getVariantKey() + Assert.assertNotEquals(originalVariant, newVariant) + assertEquals("xx", newVariant) + } + + private fun addActiveVariantToConfig(variantKey: String = "foo", weight: Double = 1.0, localeFilter: List = emptyList()) { + val testVariantEntity = ExperimentVariantEntity(variantKey, weight, localeFilter) + whenever(mockExperimentVariantRepository.getActiveVariants()).thenReturn(listOf(testVariantEntity)) + + testee.saveVariants(listOf(VariantConfig(variantKey))) + } + + private fun mockUpdateScenario(key: String) { + testee.updateAppReferrerVariant(key) + whenever(mockExperimentVariantRepository.getAppReferrerVariant()).thenReturn(key) + whenever(mockExperimentVariantRepository.getUserVariant()).thenReturn(key) + } +} diff --git a/app/src/test/java/com/duckduckgo/app/statistics/VariantManagerTest.kt b/experiments/experiments-impl/src/test/java/com/duckduckgo/experiments/impl/VariantManagerTest.kt similarity index 53% rename from app/src/test/java/com/duckduckgo/app/statistics/VariantManagerTest.kt rename to experiments/experiments-impl/src/test/java/com/duckduckgo/experiments/impl/VariantManagerTest.kt index 3b9614dc446a..43fbe4bb44fe 100644 --- a/app/src/test/java/com/duckduckgo/app/statistics/VariantManagerTest.kt +++ b/experiments/experiments-impl/src/test/java/com/duckduckgo/experiments/impl/VariantManagerTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018 DuckDuckGo + * Copyright (c) 2023 DuckDuckGo * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,18 +14,19 @@ * limitations under the License. */ -package com.duckduckgo.app.statistics +package com.duckduckgo.experiments.impl -import com.duckduckgo.app.statistics.VariantManager.Companion.DEFAULT_VARIANT -import com.duckduckgo.app.statistics.VariantManager.VariantFeature.AskForDefaultBrowserMoreThanOnce -import org.junit.Assert.* +import org.junit.Assert import org.junit.Test class VariantManagerTest { - private val variants = VariantManager.ACTIVE_VARIANTS + - VariantManager.REFERRER_VARIANTS + - DEFAULT_VARIANT + private val variants = listOf( + Variant("sc", 0.0) { true }, + Variant("se", 0.0) { true }, + Variant("ma", 1.0) { true }, + Variant("mb", 1.0) { false }, + ) // SERP Experiment(s) @@ -33,31 +34,12 @@ class VariantManagerTest { fun serpControlVariantHasExpectedWeightAndNoFeatures() { val variant = variants.first { it.key == "sc" } assertEqualsDouble(0.0, variant.weight) - assertEquals(0, variant.features.size) } @Test fun serpExperimentalVariantHasExpectedWeightAndNoFeatures() { val variant = variants.first { it.key == "se" } assertEqualsDouble(0.0, variant.weight) - assertEquals(0, variant.features.size) - } - - // AskForDefaultBrowserMoreThanOnce - - @Test - fun askForDefaultBrowserMoreThanOnceVariantHasExpectedWeightAndNoFeatures() { - val variant = variants.first { it.key == "zh" } - assertEqualsDouble(1.0, variant.weight) - assertEquals(0, variant.features.size) - } - - @Test - fun askForDefaultBrowserMoreThanOnceVariantHasExpectedWeightAndFeatures() { - val variant = variants.first { it.key == "zj" } - assertEqualsDouble(1.0, variant.weight) - assertEquals(1, variant.features.size) - assertTrue(variant.hasFeature(AskForDefaultBrowserMoreThanOnce)) } @Test @@ -65,7 +47,7 @@ class VariantManagerTest { val existingNames = mutableSetOf() variants.forEach { if (!existingNames.add(it.key)) { - fail("Duplicate variant name found: ${it.key}") + Assert.fail("Duplicate variant name found: ${it.key}") } } } @@ -77,7 +59,7 @@ class VariantManagerTest { ) { val comparison = expected.compareTo(actual) if (comparison != 0) { - fail("Doubles are not equal. Expected $expected but was $actual") + Assert.fail("Doubles are not equal. Expected $expected but was $actual") } } } diff --git a/experiments/readme.md b/experiments/readme.md new file mode 100644 index 000000000000..ed12024f70a4 --- /dev/null +++ b/experiments/readme.md @@ -0,0 +1,9 @@ +# Android Experiments +This module is responsible for persisting and allocating experiment variants to users. + +## Who can help you better understand this feature? +- Noelia Alcala +- Aitor Viana + +## More information +- [Asana: documentation](https://app.asana.com/0/72649045549333/1205616517973325/f) \ No newline at end of file diff --git a/feature-toggles/feature-toggles-api/build.gradle b/feature-toggles/feature-toggles-api/build.gradle index 1577323fa5ae..66b733c86028 100644 --- a/feature-toggles/feature-toggles-api/build.gradle +++ b/feature-toggles/feature-toggles-api/build.gradle @@ -31,6 +31,8 @@ kotlin { } dependencies { + api project(":experiments-api") + implementation Google.dagger implementation Kotlin.stdlib.jdk7 } diff --git a/feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/FeatureToggles.kt b/feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/FeatureToggles.kt index bf6f68761254..26c5cf3bf9b5 100644 --- a/feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/FeatureToggles.kt +++ b/feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/FeatureToggles.kt @@ -28,6 +28,8 @@ class FeatureToggles private constructor( private val appVersionProvider: () -> Int, private val flavorNameProvider: () -> String, private val featureName: String, + private val appVariantProvider: () -> String?, + private val forceDefaultVariant: () -> Unit, ) { private val featureToggleCache = mutableMapOf() @@ -37,12 +39,16 @@ class FeatureToggles private constructor( private var appVersionProvider: () -> Int = { Int.MAX_VALUE }, private var flavorNameProvider: () -> String = { "" }, private var featureName: String? = null, + private var appVariantProvider: () -> String? = { "" }, + private var forceDefaultVariant: () -> Unit = { /** noop **/ }, ) { fun store(store: Toggle.Store) = apply { this.store = store } fun appVersionProvider(appVersionProvider: () -> Int) = apply { this.appVersionProvider = appVersionProvider } fun flavorNameProvider(flavorNameProvider: () -> String) = apply { this.flavorNameProvider = flavorNameProvider } fun featureName(featureName: String) = apply { this.featureName = featureName } + fun appVariantProvider(variantName: () -> String?) = apply { this.appVariantProvider = variantName } + fun forceDefaultVariantProvider(forceDefaultVariant: () -> Unit) = apply { this.forceDefaultVariant = forceDefaultVariant } fun build(): FeatureToggles { val missing = StringBuilder() if (this.store == null) { @@ -54,7 +60,7 @@ class FeatureToggles private constructor( if (missing.isNotBlank()) { throw IllegalArgumentException("This following parameters can't be null: $missing") } - return FeatureToggles(this.store!!, appVersionProvider, flavorNameProvider, featureName!!) + return FeatureToggles(this.store!!, appVersionProvider, flavorNameProvider, featureName!!, appVariantProvider, forceDefaultVariant) } } @@ -86,14 +92,20 @@ class FeatureToggles private constructor( val isInternalAlwaysEnabledAnnotated: Boolean = runCatching { method.getAnnotation(Toggle.InternalAlwaysEnabled::class.java) }.getOrNull() != null + val forcesDefaultVariant: Boolean = runCatching { + method.getAnnotation(Toggle.ForcesDefaultVariantIfNull::class.java) + }.getOrNull() != null return ToggleImpl( store = store, key = getToggleNameForMethod(method), defaultValue = defaultValue, isInternalAlwaysEnabled = isInternalAlwaysEnabledAnnotated, + forcesDefaultVariant = forcesDefaultVariant, appVersionProvider = appVersionProvider, flavorNameProvider = flavorNameProvider, + appVariantProvider = appVariantProvider, + forceDefaultVariant = forceDefaultVariant, ).also { featureToggleCache[method] = it } } } @@ -156,7 +168,12 @@ interface Toggle { val enabledOverrideValue: Boolean? = null, val rollout: List? = null, val rolloutStep: Int? = null, - ) + val targets: List = emptyList(), + ) { + data class Target( + val variantKey: String, + ) + } interface Store { fun set(key: String, state: State) @@ -164,15 +181,32 @@ interface Toggle { fun get(key: String): State? } + /** + * This annotation is required. + * It specifies the default value of the feature flag when it's not remotely defined + */ @Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) annotation class DefaultValue( val defaultValue: Boolean, ) + /** + * This annotation is optional. + * It will make the feature flag ALWAYS enabled for internal builds + */ @Target(AnnotationTarget.FUNCTION) @Retention(AnnotationRetention.RUNTIME) annotation class InternalAlwaysEnabled + + /** + * This annotation is optional and it should be used in feature flags that related to experimentation. + * It will make the feature flag to set the default variant if [isEnabled] is called BEFORE any variant has been allocated. + * This annotation should be used ONLY in experiments that happen in the first (eg. onboarding) screens of the application. + */ + @Target(AnnotationTarget.FUNCTION) + @Retention(AnnotationRetention.RUNTIME) + annotation class ForcesDefaultVariantIfNull } internal class ToggleImpl constructor( @@ -180,17 +214,36 @@ internal class ToggleImpl constructor( private val key: String, private val defaultValue: Boolean, private val isInternalAlwaysEnabled: Boolean, + private val forcesDefaultVariant: Boolean, private val appVersionProvider: () -> Int, private val flavorNameProvider: () -> String = { "" }, + private val appVariantProvider: () -> String?, + private val forceDefaultVariant: () -> Unit, ) : Toggle { + + private fun Toggle.State.isVariantTreated(variant: String?): Boolean { + // if no variants a present, we consider always treated + if (this.targets.isEmpty()) { + return true + } + + return this.targets.map { it.variantKey }.contains(variant) + } + override fun isEnabled(): Boolean { fun evaluateLocalEnable(state: State): Boolean { - return state.enable && appVersionProvider.invoke() >= (state.minSupportedVersion ?: 0) + return state.enable && + state.isVariantTreated(appVariantProvider.invoke()) && + appVersionProvider.invoke() >= (state.minSupportedVersion ?: 0) } // check if it should always be enabled for internal builds if (isInternalAlwaysEnabled && flavorNameProvider.invoke().lowercase() == "internal") { return true } + // If there's not assigned variant yet and feature forces default variant, set default variant + if (appVariantProvider.invoke() == null && forcesDefaultVariant) { + forceDefaultVariant.invoke() + } // normal check return store.get(key)?.let { state -> diff --git a/feature-toggles/feature-toggles-impl/build.gradle b/feature-toggles/feature-toggles-impl/build.gradle index 226b11a26009..9b050cca2f97 100644 --- a/feature-toggles/feature-toggles-impl/build.gradle +++ b/feature-toggles/feature-toggles-impl/build.gradle @@ -40,6 +40,7 @@ dependencies { // Testing dependencies testImplementation project(':feature-toggles-test') + testImplementation project(':experiments-api') testImplementation project(':common-test') testImplementation project(':app-build-config-api') testImplementation project(':privacy-config-api') diff --git a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/api/FeatureTogglesTest.kt b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/api/FeatureTogglesTest.kt index fe7575ffd9a7..7ae745c9435b 100644 --- a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/api/FeatureTogglesTest.kt +++ b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/api/FeatureTogglesTest.kt @@ -39,8 +39,10 @@ class FeatureTogglesTest { toggleStore = FakeToggleStore() feature = FeatureToggles.Builder() .store(toggleStore) + .appVariantProvider { provider.variantKey } .appVersionProvider { provider.version } .flavorNameProvider { provider.flavorName } + .forceDefaultVariantProvider { provider.variantKey = "" } .featureName("test") .build() .create(TestFeature::class.java) @@ -128,6 +130,20 @@ class FeatureTogglesTest { assertTrue(feature.internal().isEnabled()) } + @Test + fun testForcesDefaultVariantIfNull() { + assertNull(provider.variantKey) + assertFalse(feature.forcesDefaultVariant().isEnabled()) + assertEquals("", provider.variantKey) + } + + @Test + fun testSkipForcesDefaultVariantWhenNotNull() { + provider.variantKey = "ma" + assertFalse(feature.forcesDefaultVariant().isEnabled()) + assertEquals("ma", provider.variantKey) + } + @Test(expected = java.lang.IllegalArgumentException::class) fun whenMethodWithArgumentsThenThrow() { feature.methodWithArguments("") @@ -342,6 +358,83 @@ class FeatureTogglesTest { toggleStore.set("test_enabledByDefault", state.copy(enable = false)) assertFalse(feature.enabledByDefault().isEnabled()) } + + @Test + fun whenNoMatchingVariantThenFeatureIsDisabled() { + val state = Toggle.State( + remoteEnableState = null, + enable = true, + targets = listOf(Toggle.State.Target("ma")), + ) + + // Use directly the store because setEnabled() populates the local state when the remote state is null + toggleStore.set("test_disableByDefault", state) + assertFalse(feature.disableByDefault().isEnabled()) + + // Use directly the store because setEnabled() populates the local state when the remote state is null + toggleStore.set("test_enabledByDefault", state.copy(enable = false)) + assertFalse(feature.enabledByDefault().isEnabled()) + } + + @Test + fun whenMatchingVariantThenReturnFeatureState() { + provider.variantKey = "ma" + val state = Toggle.State( + remoteEnableState = null, + enable = true, + targets = listOf(Toggle.State.Target(provider.variantKey!!)), + ) + + // Use directly the store because setEnabled() populates the local state when the remote state is null + toggleStore.set("test_disableByDefault", state) + assertTrue(feature.disableByDefault().isEnabled()) + + // Use directly the store because setEnabled() populates the local state when the remote state is null + toggleStore.set("test_enabledByDefault", state.copy(enable = false)) + assertFalse(feature.enabledByDefault().isEnabled()) + } + + @Test + fun whenMultipleNotMatchingVariantThenReturnFeatureState() { + provider.variantKey = "zz" + val state = Toggle.State( + remoteEnableState = null, + enable = true, + targets = listOf( + Toggle.State.Target("ma"), + Toggle.State.Target("mb"), + ), + ) + + // Use directly the store because setEnabled() populates the local state when the remote state is null + toggleStore.set("test_disableByDefault", state) + assertFalse(feature.disableByDefault().isEnabled()) + + // Use directly the store because setEnabled() populates the local state when the remote state is null + toggleStore.set("test_enabledByDefault", state.copy(enable = false)) + assertFalse(feature.enabledByDefault().isEnabled()) + } + + @Test + fun whenAnyMatchingVariantThenReturnFeatureState() { + provider.variantKey = "zz" + val state = Toggle.State( + remoteEnableState = null, + enable = true, + targets = listOf( + Toggle.State.Target("ma"), + Toggle.State.Target("zz"), + ), + ) + + // Use directly the store because setEnabled() populates the local state when the remote state is null + toggleStore.set("test_disableByDefault", state) + assertTrue(feature.disableByDefault().isEnabled()) + + // Use directly the store because setEnabled() populates the local state when the remote state is null + toggleStore.set("test_enabledByDefault", state.copy(enable = false)) + assertFalse(feature.enabledByDefault().isEnabled()) + } } interface TestFeature { @@ -367,9 +460,14 @@ interface TestFeature { @Toggle.DefaultValue(false) @Toggle.InternalAlwaysEnabled fun internal(): Toggle + + @Toggle.DefaultValue(false) + @Toggle.ForcesDefaultVariantIfNull + fun forcesDefaultVariant(): Toggle } private class FakeProvider { var version = Int.MAX_VALUE var flavorName = "" + var variantKey: String? = null } diff --git a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/ContributesRemoteFeatureCodeGeneratorTest.kt b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/ContributesRemoteFeatureCodeGeneratorTest.kt index 406ca359b74e..373581040fc0 100644 --- a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/ContributesRemoteFeatureCodeGeneratorTest.kt +++ b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/ContributesRemoteFeatureCodeGeneratorTest.kt @@ -21,6 +21,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.appbuildconfig.api.BuildFlavor.* +import com.duckduckgo.experiments.api.VariantConfig +import com.duckduckgo.experiments.api.VariantManager import com.duckduckgo.feature.toggles.api.FakeToggleStore import com.duckduckgo.feature.toggles.api.FeatureExceptions import com.duckduckgo.feature.toggles.api.FeatureSettings @@ -40,6 +42,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.mock +import org.mockito.kotlin.times import org.mockito.kotlin.whenever @RunWith(AndroidJUnit4::class) @@ -48,17 +51,19 @@ class ContributesRemoteFeatureCodeGeneratorTest { private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext private lateinit var testFeature: TestTriggerFeature private val appBuildConfig: AppBuildConfig = mock() -// private lateinit var provider: FakeProvider + private lateinit var variantManager: FakeVariantManager @Before fun setup() { -// provider = FakeProvider(appBuildConfig) + variantManager = FakeVariantManager() whenever(appBuildConfig.flavor).thenReturn(PLAY) testFeature = FeatureToggles.Builder( FakeToggleStore(), featureName = "testFeature", appVersionProvider = { appBuildConfig.versionCode }, flavorNameProvider = { appBuildConfig.flavor.name }, + appVariantProvider = { variantManager.getVariantKey() }, + forceDefaultVariant = { variantManager.saveVariants(emptyList()) }, ).build().create(TestTriggerFeature::class.java) } @@ -1053,6 +1058,275 @@ class ContributesRemoteFeatureCodeGeneratorTest { } } + @Test + fun `test variant parsing when no remote variant provided`() { + val feature = generatedFeatureNewInstance() + + val privacyPlugin = (feature as PrivacyFeaturePlugin) + + // all disabled + assertTrue( + privacyPlugin.store( + "testFeature", + """ + { + "state": "enabled", + "features": { + "fooFeature": { + "state": "enabled" + } + } + } + """.trimIndent(), + ), + ) + + assertTrue(testFeature.self().isEnabled()) + assertTrue(testFeature.fooFeature().isEnabled()) + assertEquals(emptyList(), testFeature.fooFeature().getRawStoredState()!!.targets) + } + + @Test + fun `test variant parsing`() { + variantManager.variant = "mc" + val feature = generatedFeatureNewInstance() + + val privacyPlugin = (feature as PrivacyFeaturePlugin) + + // all disabled + assertTrue( + privacyPlugin.store( + "testFeature", + """ + { + "state": "enabled", + "features": { + "fooFeature": { + "state": "enabled", + "targets": [ + { + "variantKey": "ma" + }, + { + "variantKey": "mb" + } + ] + }, + "variantFeature": { + "state": "enabled", + "targets": [ + { + "variantKey": "mc" + } + ] + } + } + } + """.trimIndent(), + ), + ) + + assertTrue(testFeature.self().isEnabled()) + assertFalse(testFeature.fooFeature().isEnabled()) + assertEquals( + listOf( + Toggle.State.Target("ma"), + Toggle.State.Target("mb"), + ), + testFeature.fooFeature().getRawStoredState()!!.targets, + ) + assertTrue(testFeature.variantFeature().isEnabled()) + assertEquals( + listOf( + Toggle.State.Target("mc"), + ), + testFeature.variantFeature().getRawStoredState()!!.targets, + ) + } + + @Test + fun `test variant when assigned variant key is null`() { + variantManager.variant = null + val feature = generatedFeatureNewInstance() + + val privacyPlugin = (feature as PrivacyFeaturePlugin) + + // all disabled + assertTrue( + privacyPlugin.store( + "testFeature", + """ + { + "state": "enabled", + "features": { + "fooFeature": { + "state": "enabled", + "targets": [ + { + "variantKey": "ma" + }, + { + "variantKey": "mb" + } + ] + }, + "variantFeature": { + "state": "enabled", + "targets": [ + { + "variantKey": "mc" + } + ] + } + } + } + """.trimIndent(), + ), + ) + + assertTrue(testFeature.self().isEnabled()) + assertFalse(testFeature.fooFeature().isEnabled()) + assertEquals(0, variantManager.saveVariantsCallCounter) + assertEquals( + listOf( + Toggle.State.Target("ma"), + Toggle.State.Target("mb"), + ), + testFeature.fooFeature().getRawStoredState()!!.targets, + ) + assertFalse(testFeature.variantFeature().isEnabled()) + assertEquals(0, variantManager.saveVariantsCallCounter) + assertEquals( + listOf( + Toggle.State.Target("mc"), + ), + testFeature.variantFeature().getRawStoredState()!!.targets, + ) + } + + @Test + fun `test feature disabled and forces variant when variant is null`() { + variantManager.variant = null + val feature = generatedFeatureNewInstance() + + val privacyPlugin = (feature as PrivacyFeaturePlugin) + + // all disabled + assertTrue( + privacyPlugin.store( + "testFeature", + """ + { + "state": "enabled", + "features": { + "variantFeatureForcesDefaultVariant": { + "state": "enabled", + "targets": [ + { + "variantKey": "mc" + } + ] + } + } + } + """.trimIndent(), + ), + ) + + assertTrue(testFeature.self().isEnabled()) + assertFalse(testFeature.variantFeatureForcesDefaultVariant().isEnabled()) + assertEquals(1, variantManager.saveVariantsCallCounter) + assertEquals("", variantManager.getVariantKey()) + assertEquals( + listOf( + Toggle.State.Target("mc"), + ), + testFeature.variantFeatureForcesDefaultVariant().getRawStoredState()!!.targets, + ) + } + + @Test + fun `test feature enabled and forces variant when variant is null`() { + variantManager.variant = null + val feature = generatedFeatureNewInstance() + + val privacyPlugin = (feature as PrivacyFeaturePlugin) + + // all disabled + assertTrue( + privacyPlugin.store( + "testFeature", + """ + { + "state": "enabled", + "features": { + "variantFeatureForcesDefaultVariant": { + "state": "enabled", + "targets": [ + { + "variantKey": "" + } + ] + } + } + } + """.trimIndent(), + ), + ) + + assertTrue(testFeature.self().isEnabled()) + assertTrue(testFeature.variantFeatureForcesDefaultVariant().isEnabled()) + assertEquals(1, variantManager.saveVariantsCallCounter) + assertEquals("", variantManager.getVariantKey()) + assertEquals( + listOf( + Toggle.State.Target(""), + ), + testFeature.variantFeatureForcesDefaultVariant().getRawStoredState()!!.targets, + ) + } + + @Test + fun `test feature does not force variant when already assigned`() { + variantManager.variant = "mc" + val feature = generatedFeatureNewInstance() + + val privacyPlugin = (feature as PrivacyFeaturePlugin) + + // all disabled + assertTrue( + privacyPlugin.store( + "testFeature", + """ + { + "state": "enabled", + "features": { + "variantFeatureForcesDefaultVariant": { + "state": "enabled", + "targets": [ + { + "variantKey": "mc" + } + ] + } + } + } + """.trimIndent(), + ), + ) + + assertTrue(testFeature.self().isEnabled()) + assertTrue(testFeature.variantFeatureForcesDefaultVariant().isEnabled()) + assertEquals(0, variantManager.saveVariantsCallCounter) + assertEquals("mc", variantManager.getVariantKey()) + assertEquals( + listOf( + Toggle.State.Target("mc"), + ), + testFeature.variantFeatureForcesDefaultVariant().getRawStoredState()!!.targets, + ) + } + private fun generatedFeatureNewInstance(): Any { return Class .forName("com.duckduckgo.feature.toggles.codegen.TestTriggerFeature_RemoteFeature") @@ -1061,12 +1335,14 @@ class ContributesRemoteFeatureCodeGeneratorTest { FeatureSettings.Store::class.java, dagger.Lazy::class.java as Class<*>, AppBuildConfig::class.java, + VariantManager::class.java, Context::class.java, ).newInstance( FeatureExceptions.EMPTY_STORE, FeatureSettings.EMPTY_STORE, Lazy { testFeature }, appBuildConfig, + variantManager, context, ) } @@ -1075,3 +1351,25 @@ class ContributesRemoteFeatureCodeGeneratorTest { return getRawStoredState()?.rolloutStep } } + +private class FakeVariantManager : VariantManager { + var saveVariantsCallCounter = 0 + var variant: String? = null + + override fun defaultVariantKey(): String { + TODO("Not yet implemented") + } + + override fun getVariantKey(): String? { + return variant + } + + override fun updateAppReferrerVariant(variant: String) { + TODO("Not yet implemented") + } + + override fun saveVariants(variants: List) { + saveVariantsCallCounter++ + variant = "" + } +} diff --git a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/TestTriggerFeature.kt b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/TestTriggerFeature.kt index 122a227e1f82..e2b5c4012022 100644 --- a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/TestTriggerFeature.kt +++ b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/TestTriggerFeature.kt @@ -19,6 +19,7 @@ package com.duckduckgo.feature.toggles.codegen import com.duckduckgo.anvil.annotations.ContributesRemoteFeature import com.duckduckgo.feature.toggles.api.Toggle import com.duckduckgo.feature.toggles.api.Toggle.DefaultValue +import com.duckduckgo.feature.toggles.api.Toggle.ForcesDefaultVariantIfNull import com.duckduckgo.feature.toggles.api.Toggle.InternalAlwaysEnabled abstract class TriggerTestScope private constructor() @@ -47,4 +48,11 @@ interface TestTriggerFeature { @DefaultValue(false) fun defaultFalse(): Toggle + + @DefaultValue(false) + fun variantFeature(): Toggle + + @DefaultValue(false) + @ForcesDefaultVariantIfNull + fun variantFeatureForcesDefaultVariant(): Toggle } diff --git a/privacy-config/privacy-config-api/src/main/java/com/duckduckgo/privacy/config/api/PrivacyConfigCallbackPlugin.kt b/privacy-config/privacy-config-api/src/main/java/com/duckduckgo/privacy/config/api/PrivacyConfigCallbackPlugin.kt new file mode 100644 index 000000000000..8397707f8b16 --- /dev/null +++ b/privacy-config/privacy-config-api/src/main/java/com/duckduckgo/privacy/config/api/PrivacyConfigCallbackPlugin.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 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.privacy.config.api + +/** Public interface for privacy config related callbacks **/ +interface PrivacyConfigCallbackPlugin { + + /** + * Notifies that onPrivacyConfigDownloaded event occurred. + * This method will be called every time it downloads a new version of the privacy config. + */ + fun onPrivacyConfigDownloaded() +} diff --git a/privacy-config/privacy-config-impl/build.gradle b/privacy-config/privacy-config-impl/build.gradle index 3e57fe115637..957ae4615f31 100644 --- a/privacy-config/privacy-config-impl/build.gradle +++ b/privacy-config/privacy-config-impl/build.gradle @@ -35,6 +35,7 @@ dependencies { implementation project(path: ':privacy-config-store') implementation project(path: ':content-scope-scripts-api') implementation project(path: ':browser-api') + implementation project(path: ':experiments-api') implementation Kotlin.stdlib.jdk7 implementation AndroidX.appCompat diff --git a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/PrivacyConfigPersister.kt b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/PrivacyConfigPersister.kt index 3753b4869a04..3dcbe36d7aae 100644 --- a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/PrivacyConfigPersister.kt +++ b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/PrivacyConfigPersister.kt @@ -22,7 +22,9 @@ import androidx.annotation.WorkerThread import androidx.core.content.edit import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.experiments.api.VariantManager import com.duckduckgo.privacy.config.api.PrivacyFeaturePlugin +import com.duckduckgo.privacy.config.impl.VariantManagerPlugin.Companion.VARIANT_MANAGER_FEATURE_NAME import com.duckduckgo.privacy.config.impl.di.ConfigPersisterPreferences import com.duckduckgo.privacy.config.impl.models.JsonPrivacyConfig import com.duckduckgo.privacy.config.store.PrivacyConfig @@ -32,14 +34,21 @@ import com.duckduckgo.privacy.config.store.PrivacyFeatureTogglesRepository import com.duckduckgo.privacy.config.store.UnprotectedTemporaryEntity import com.duckduckgo.privacy.config.store.features.unprotectedtemporary.UnprotectedTemporaryRepository import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides import dagger.SingleInstanceIn import javax.inject.Inject +import javax.inject.Qualifier import org.threeten.bp.LocalDateTime import org.threeten.bp.format.DateTimeFormatter import timber.log.Timber interface PrivacyConfigPersister { - suspend fun persistPrivacyConfig(jsonPrivacyConfig: JsonPrivacyConfig, eTag: String? = null) + suspend fun persistPrivacyConfig( + jsonPrivacyConfig: JsonPrivacyConfig, + eTag: String? = null, + ) } private const val PRIVACY_SIGNATURE_KEY = "plugin_signature" @@ -49,6 +58,7 @@ private const val PRIVACY_SIGNATURE_KEY = "plugin_signature" @ContributesBinding(AppScope::class) class RealPrivacyConfigPersister @Inject constructor( private val privacyFeaturePluginPoint: PluginPoint, + @PluginVariantManager private val variantManagerPlugin: PrivacyFeaturePlugin, private val privacyFeatureTogglesRepository: PrivacyFeatureTogglesRepository, private val unprotectedTemporaryRepository: UnprotectedTemporaryRepository, private val privacyConfigRepository: PrivacyConfigRepository, @@ -57,7 +67,10 @@ class RealPrivacyConfigPersister @Inject constructor( @ConfigPersisterPreferences private val persisterPreferences: SharedPreferences, ) : PrivacyConfigPersister { - override suspend fun persistPrivacyConfig(jsonPrivacyConfig: JsonPrivacyConfig, eTag: String?) { + override suspend fun persistPrivacyConfig( + jsonPrivacyConfig: JsonPrivacyConfig, + eTag: String?, + ) { val privacyConfig = privacyConfigRepository.get() val newVersion = jsonPrivacyConfig.version val previousVersion = privacyConfig?.version ?: 0 @@ -95,6 +108,11 @@ class RealPrivacyConfigPersister @Inject constructor( unProtectedExceptions.add(UnprotectedTemporaryEntity(it.domain, it.reason.orEmpty())) } unprotectedTemporaryRepository.updateAll(unProtectedExceptions) + // First store the variants... + jsonPrivacyConfig.experimentalVariants?.let { jsonObject -> + variantManagerPlugin.store(VARIANT_MANAGER_FEATURE_NAME, jsonObject.toString()) + } + // Then feature flags jsonPrivacyConfig.features.forEach { feature -> feature.value?.let { jsonObject -> privacyFeaturePluginPoint.getPlugins().firstOrNull { feature.key == it.featureName }?.let { featurePlugin -> @@ -126,3 +144,17 @@ class RealPrivacyConfigPersister @Inject constructor( fun PluginPoint.signature(): Int { return this.getPlugins().sumOf { it.featureName.hashCode() } } + +@Retention(AnnotationRetention.BINARY) +@Qualifier +private annotation class PluginVariantManager + +@ContributesTo(AppScope::class) +@Module +object VariantManagerPluginModule { + @Provides + @PluginVariantManager + fun provideVariantManagerPlugin(variantManager: VariantManager): PrivacyFeaturePlugin { + return VariantManagerPlugin(variantManager) + } +} diff --git a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/PrivacyConfigDownloader.kt b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigDownloader.kt similarity index 57% rename from privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/PrivacyConfigDownloader.kt rename to privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigDownloader.kt index 1a3c9265a436..614144f322e2 100644 --- a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/PrivacyConfigDownloader.kt +++ b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigDownloader.kt @@ -18,16 +18,29 @@ package com.duckduckgo.privacy.config.impl import androidx.annotation.WorkerThread import com.duckduckgo.common.utils.extensions.extractETag +import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.privacy.config.impl.ConfigDownloadResult.Error -import com.duckduckgo.privacy.config.impl.ConfigDownloadResult.Success +import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin +import com.duckduckgo.privacy.config.impl.PrivacyConfigDownloader.ConfigDownloadResult.Error +import com.duckduckgo.privacy.config.impl.PrivacyConfigDownloader.ConfigDownloadResult.Success import com.duckduckgo.privacy.config.impl.network.PrivacyConfigService import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject import timber.log.Timber +/** Public interface for download remote privacy config */ interface PrivacyConfigDownloader { + /** + * This method will download remote config and returns the [ConfigDownloadResult] + * @return [ConfigDownloadResult.Success] if remote config has been downloaded correctly or + * [ConfigDownloadResult.Error] otherwise. + */ suspend fun download(): ConfigDownloadResult + + sealed class ConfigDownloadResult { + object Success : ConfigDownloadResult() + data class Error(val error: String?) : ConfigDownloadResult() + } } @WorkerThread @@ -35,24 +48,23 @@ interface PrivacyConfigDownloader { class RealPrivacyConfigDownloader @Inject constructor( private val privacyConfigService: PrivacyConfigService, private val privacyConfigPersister: PrivacyConfigPersister, + private val privacyConfigCallbacks: PluginPoint, ) : PrivacyConfigDownloader { - override suspend fun download(): ConfigDownloadResult { + override suspend fun download(): PrivacyConfigDownloader.ConfigDownloadResult { Timber.d("Downloading privacy config") val response = runCatching { privacyConfigService.privacyConfig() }.onSuccess { response -> - runCatching { - val eTag = response.headers().extractETag() - val body = response.body() - if (body != null) { - privacyConfigPersister.persistPrivacyConfig(body, eTag) - } else { - return Error(null) + val eTag = response.headers().extractETag() + response.body()?.let { + runCatching { + privacyConfigPersister.persistPrivacyConfig(it, eTag) + privacyConfigCallbacks.getPlugins().forEach { callback -> callback.onPrivacyConfigDownloaded() } + }.onFailure { + return Error(it.localizedMessage) } - }.onFailure { - return Error(it.localizedMessage) - } + } ?: return Error(null) }.onFailure { Timber.w(it.localizedMessage) } @@ -64,8 +76,3 @@ class RealPrivacyConfigDownloader @Inject constructor( } } } - -sealed class ConfigDownloadResult { - object Success : ConfigDownloadResult() - data class Error(val error: String?) : ConfigDownloadResult() -} diff --git a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/VariantManagerPlugin.kt b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/VariantManagerPlugin.kt new file mode 100644 index 000000000000..be2fa7cb5960 --- /dev/null +++ b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/VariantManagerPlugin.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023 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.privacy.config.impl + +import com.duckduckgo.experiments.api.VariantConfig +import com.duckduckgo.experiments.api.VariantManager +import com.duckduckgo.privacy.config.api.PrivacyFeaturePlugin +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import timber.log.Timber + +internal class VariantManagerPlugin constructor( + private val variantManager: VariantManager, +) : PrivacyFeaturePlugin { + + override fun store( + featureName: String, + jsonString: String, + ): Boolean { + val moshi = Moshi.Builder().build() + val jsonAdapter: JsonAdapter = + moshi.adapter(VariantManagerConfig::class.java) + + val variantManagerConfig: VariantManagerConfig? = runCatching { + jsonAdapter.fromJson(jsonString) + }.onFailure { + Timber.e( + """ + Error: ${it.message} + Parsing jsonString: $jsonString + """.trimIndent(), + ) + }.getOrThrow() + + return variantManagerConfig?.variants?.takeIf { it.isNotEmpty() }?.let { variants -> + variantManager.saveVariants(variants) + true + } ?: false + } + + override val featureName = VARIANT_MANAGER_FEATURE_NAME + + companion object { + const val VARIANT_MANAGER_FEATURE_NAME = "experimentalVariants" + } +} + +internal data class VariantManagerConfig( + val variants: List, +) diff --git a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/models/JsonPrivacyConfig.kt b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/models/JsonPrivacyConfig.kt index 2cd624da5806..471269aa4846 100644 --- a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/models/JsonPrivacyConfig.kt +++ b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/models/JsonPrivacyConfig.kt @@ -24,4 +24,5 @@ data class JsonPrivacyConfig( val readme: String, val features: Map, val unprotectedTemporary: List, + val experimentalVariants: JSONObject?, ) diff --git a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/plugins/PrivacyConfigCallbackPluginPoint.kt b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/plugins/PrivacyConfigCallbackPluginPoint.kt new file mode 100644 index 000000000000..4ad7cdfc5e92 --- /dev/null +++ b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/plugins/PrivacyConfigCallbackPluginPoint.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 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.privacy.config.impl.plugins + +import com.duckduckgo.anvil.annotations.ContributesPluginPoint +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin + +@ContributesPluginPoint( + scope = AppScope::class, + boundType = PrivacyConfigCallbackPlugin::class, +) +interface PrivacyConfigCallbackPluginPoint diff --git a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/workers/RemoteConfigDownloadWorker.kt b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/workers/RemoteConfigDownloadWorker.kt index 5e42e740d0dd..b3d372df49e7 100644 --- a/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/workers/RemoteConfigDownloadWorker.kt +++ b/privacy-config/privacy-config-impl/src/main/java/com/duckduckgo/privacy/config/impl/workers/RemoteConfigDownloadWorker.kt @@ -28,8 +28,8 @@ import com.duckduckgo.anvil.annotations.ContributesWorker import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.AppScope -import com.duckduckgo.privacy.config.impl.ConfigDownloadResult.Success import com.duckduckgo.privacy.config.impl.PrivacyConfigDownloader +import com.duckduckgo.privacy.config.impl.PrivacyConfigDownloader.ConfigDownloadResult.Success import com.squareup.anvil.annotations.ContributesMultibinding import dagger.SingleInstanceIn import java.util.concurrent.TimeUnit diff --git a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigDownloaderTest.kt b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigDownloaderTest.kt index fb40ede3a6aa..d29f62cad1ef 100644 --- a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigDownloaderTest.kt +++ b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigDownloaderTest.kt @@ -19,8 +19,10 @@ package com.duckduckgo.privacy.config.impl import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.feature.toggles.api.FeatureExceptions.FeatureException -import com.duckduckgo.privacy.config.impl.ConfigDownloadResult.Error -import com.duckduckgo.privacy.config.impl.ConfigDownloadResult.Success +import com.duckduckgo.privacy.config.impl.PrivacyConfigDownloader.ConfigDownloadResult.Error +import com.duckduckgo.privacy.config.impl.PrivacyConfigDownloader.ConfigDownloadResult.Success +import com.duckduckgo.privacy.config.impl.RealPrivacyConfigPersisterTest.FakeFakePrivacyConfigCallbackPluginPoint +import com.duckduckgo.privacy.config.impl.RealPrivacyConfigPersisterTest.FakePrivacyConfigCallbackPlugin import com.duckduckgo.privacy.config.impl.models.JsonPrivacyConfig import com.duckduckgo.privacy.config.impl.network.PrivacyConfigService import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -45,10 +47,11 @@ class RealPrivacyConfigDownloaderTest { lateinit var testee: RealPrivacyConfigDownloader private val mockPrivacyConfigPersister: PrivacyConfigPersister = mock() + private val pluginPoint = FakeFakePrivacyConfigCallbackPluginPoint(listOf(FakePrivacyConfigCallbackPlugin())) @Before fun before() { - testee = RealPrivacyConfigDownloader(TestPrivacyConfigService(), mockPrivacyConfigPersister) + testee = RealPrivacyConfigDownloader(TestPrivacyConfigService(), mockPrivacyConfigPersister, pluginPoint) } @Test @@ -58,6 +61,7 @@ class RealPrivacyConfigDownloaderTest { RealPrivacyConfigDownloader( TestFailingPrivacyConfigService(), mockPrivacyConfigPersister, + pluginPoint, ) assertTrue(testee.download() is Error) } @@ -87,7 +91,8 @@ class RealPrivacyConfigDownloaderTest { version = 1, readme = "readme", features = mapOf(FEATURE_NAME to JSONObject(FEATURE_JSON)), - unprotectedTemporaryList, + unprotectedTemporary = unprotectedTemporaryList, + experimentalVariants = VARIANT_MANAGER_JSON, ), ) } @@ -97,5 +102,6 @@ class RealPrivacyConfigDownloaderTest { private const val FEATURE_NAME = "test" private const val FEATURE_JSON = "{\"state\": \"enabled\"}" val unprotectedTemporaryList = listOf(FeatureException("example.com", "reason")) + private val VARIANT_MANAGER_JSON = null } } diff --git a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigPersisterTest.kt b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigPersisterTest.kt index 52306d7c63d5..4c985bb8e311 100644 --- a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigPersisterTest.kt +++ b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/RealPrivacyConfigPersisterTest.kt @@ -24,6 +24,7 @@ import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.test.api.InMemorySharedPreferences import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.feature.toggles.api.FeatureExceptions.FeatureException +import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin import com.duckduckgo.privacy.config.api.PrivacyFeatureName import com.duckduckgo.privacy.config.api.PrivacyFeaturePlugin import com.duckduckgo.privacy.config.impl.models.JsonPrivacyConfig @@ -63,6 +64,7 @@ class RealPrivacyConfigPersisterTest { private lateinit var privacyRepository: PrivacyConfigRepository private lateinit var unprotectedTemporaryRepository: UnprotectedTemporaryRepository private val pluginPoint = FakePrivacyFeaturePluginPoint(listOf(FakePrivacyFeaturePlugin())) + private val variantManagerPlugin = FakePrivacyVariantManagerPlugin() private lateinit var sharedPreferences: SharedPreferences private val context = RuntimeEnvironment.getApplication() @@ -79,6 +81,7 @@ class RealPrivacyConfigPersisterTest { testee = RealPrivacyConfigPersister( pluginPoint, + variantManagerPlugin, mockTogglesRepository, unprotectedTemporaryRepository, privacyRepository, @@ -217,7 +220,8 @@ class RealPrivacyConfigPersisterTest { version = 2, readme = "readme", features = mapOf(FEATURE_NAME to JSONObject(FEATURE_JSON)), - unprotectedTemporaryList, + unprotectedTemporary = unprotectedTemporaryList, + experimentalVariants = VARIANT_MANAGER_JSON, ) } @@ -243,9 +247,34 @@ class RealPrivacyConfigPersisterTest { PrivacyFeatureName.GpcFeatureName.value } + class FakePrivacyVariantManagerPlugin : PrivacyFeaturePlugin { + + override fun store( + featureName: String, + jsonString: String, + ): Boolean { + return true + } + + override val featureName = "experimentalVariants" + } + + class FakeFakePrivacyConfigCallbackPluginPoint( + private val plugins: List, + ) : PluginPoint { + override fun getPlugins(): Collection { + return plugins + } + } + + class FakePrivacyConfigCallbackPlugin : PrivacyConfigCallbackPlugin { + override fun onPrivacyConfigDownloaded() {} + } + companion object { private const val FEATURE_NAME = "gpc" private const val FEATURE_JSON = "{\"state\": \"enabled\"}" val unprotectedTemporaryList = listOf(FeatureException("example.com", "reason")) + private val VARIANT_MANAGER_JSON = null } } diff --git a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/ReferenceTestUtilities.kt b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/ReferenceTestUtilities.kt index c44fb928d3d0..252406eb827d 100644 --- a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/ReferenceTestUtilities.kt +++ b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/ReferenceTestUtilities.kt @@ -20,6 +20,7 @@ import com.duckduckgo.common.test.FileUtilities import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.privacy.config.api.PrivacyFeaturePlugin +import com.duckduckgo.privacy.config.impl.RealPrivacyConfigPersisterTest.FakePrivacyVariantManagerPlugin import com.duckduckgo.privacy.config.impl.features.contentblocking.ContentBlockingPlugin import com.duckduckgo.privacy.config.impl.features.drm.DrmPlugin import com.duckduckgo.privacy.config.impl.features.gpc.GpcPlugin @@ -52,7 +53,7 @@ import org.mockito.kotlin.mock @ExperimentalCoroutinesApi class ReferenceTestUtilities( db: PrivacyConfigDatabase, - val dispatcherProvider: DispatcherProvider, + dispatcherProvider: DispatcherProvider, ) { private val moshi = Moshi.Builder().add(JSONObjectAdapter()).build() @@ -88,6 +89,10 @@ class ReferenceTestUtilities( return FakePrivacyFeaturePluginPoint(getPrivacyFeaturePlugins()) } + fun getVariantManagerPlugin(): PrivacyFeaturePlugin { + return FakePrivacyVariantManagerPlugin() + } + internal class FakePrivacyFeaturePluginPoint(private val plugins: Collection) : PluginPoint { override fun getPlugins(): Collection { diff --git a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/referencetests/privacyconfig/PrivacyConfigDisabledReferenceTest.kt b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/referencetests/privacyconfig/PrivacyConfigDisabledReferenceTest.kt index d78649d1fb70..6f4e1c149202 100644 --- a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/referencetests/privacyconfig/PrivacyConfigDisabledReferenceTest.kt +++ b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/referencetests/privacyconfig/PrivacyConfigDisabledReferenceTest.kt @@ -84,6 +84,7 @@ class PrivacyConfigDisabledReferenceTest(private val testCase: TestCase) { testee = RealPrivacyConfigPersister( referenceTestUtilities.getPrivacyFeaturePluginPoint(), + referenceTestUtilities.getVariantManagerPlugin(), mockTogglesRepository, referenceTestUtilities.unprotectedTemporaryRepository, referenceTestUtilities.privacyRepository, diff --git a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/referencetests/privacyconfig/PrivacyConfigEnabledReferenceTest.kt b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/referencetests/privacyconfig/PrivacyConfigEnabledReferenceTest.kt index 922cbd4dfa82..23f552206220 100644 --- a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/referencetests/privacyconfig/PrivacyConfigEnabledReferenceTest.kt +++ b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/referencetests/privacyconfig/PrivacyConfigEnabledReferenceTest.kt @@ -83,6 +83,7 @@ class PrivacyConfigEnabledReferenceTest(private val testCase: TestCase) { referenceTestUtilities = ReferenceTestUtilities(db, coroutineRule.testDispatcherProvider) testee = RealPrivacyConfigPersister( referenceTestUtilities.getPrivacyFeaturePluginPoint(), + referenceTestUtilities.getVariantManagerPlugin(), mockTogglesRepository, referenceTestUtilities.unprotectedTemporaryRepository, referenceTestUtilities.privacyRepository, diff --git a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/referencetests/privacyconfig/PrivacyConfigGlobalExceptionsReferenceTest.kt b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/referencetests/privacyconfig/PrivacyConfigGlobalExceptionsReferenceTest.kt index eda4fd9c6f8c..098d70547644 100644 --- a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/referencetests/privacyconfig/PrivacyConfigGlobalExceptionsReferenceTest.kt +++ b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/referencetests/privacyconfig/PrivacyConfigGlobalExceptionsReferenceTest.kt @@ -122,6 +122,7 @@ class PrivacyConfigGlobalExceptionsReferenceTest(private val testCase: TestCase) referenceTestUtilities = ReferenceTestUtilities(db, coroutineRule.testDispatcherProvider) privacyConfigPersister = RealPrivacyConfigPersister( referenceTestUtilities.getPrivacyFeaturePluginPoint(), + referenceTestUtilities.getVariantManagerPlugin(), mockTogglesRepository, referenceTestUtilities.unprotectedTemporaryRepository, referenceTestUtilities.privacyRepository, diff --git a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/referencetests/privacyconfig/PrivacyConfigLocalExceptionsReferenceTest.kt b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/referencetests/privacyconfig/PrivacyConfigLocalExceptionsReferenceTest.kt index 14eff9ab6be8..fcce63976fd7 100644 --- a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/referencetests/privacyconfig/PrivacyConfigLocalExceptionsReferenceTest.kt +++ b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/referencetests/privacyconfig/PrivacyConfigLocalExceptionsReferenceTest.kt @@ -120,6 +120,7 @@ class PrivacyConfigLocalExceptionsReferenceTest(private val testCase: TestCase) referenceTestUtilities = ReferenceTestUtilities(db, coroutineRule.testDispatcherProvider) privacyConfigPersister = RealPrivacyConfigPersister( referenceTestUtilities.getPrivacyFeaturePluginPoint(), + referenceTestUtilities.getVariantManagerPlugin(), mockTogglesRepository, referenceTestUtilities.unprotectedTemporaryRepository, referenceTestUtilities.privacyRepository, diff --git a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/referencetests/privacyconfig/PrivacyConfigMissingReferenceTest.kt b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/referencetests/privacyconfig/PrivacyConfigMissingReferenceTest.kt index 1a39566e1425..ec716afadc5b 100644 --- a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/referencetests/privacyconfig/PrivacyConfigMissingReferenceTest.kt +++ b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/referencetests/privacyconfig/PrivacyConfigMissingReferenceTest.kt @@ -85,6 +85,7 @@ class PrivacyConfigMissingReferenceTest(private val testCase: TestCase) { testee = RealPrivacyConfigPersister( referenceTestUtilities.getPrivacyFeaturePluginPoint(), + referenceTestUtilities.getVariantManagerPlugin(), mockTogglesRepository, referenceTestUtilities.unprotectedTemporaryRepository, referenceTestUtilities.privacyRepository, diff --git a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/workers/PrivacyConfigDownloadWorkerTest.kt b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/workers/PrivacyConfigDownloadWorkerTest.kt index 9b26c15675a8..ca44937b25a2 100644 --- a/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/workers/PrivacyConfigDownloadWorkerTest.kt +++ b/privacy-config/privacy-config-impl/src/test/java/com/duckduckgo/privacy/config/impl/workers/PrivacyConfigDownloadWorkerTest.kt @@ -20,9 +20,9 @@ import android.content.Context import androidx.work.ListenableWorker import androidx.work.testing.TestListenableWorkerBuilder import com.duckduckgo.common.test.CoroutineTestRule -import com.duckduckgo.privacy.config.impl.ConfigDownloadResult.Error -import com.duckduckgo.privacy.config.impl.ConfigDownloadResult.Success import com.duckduckgo.privacy.config.impl.PrivacyConfigDownloader +import com.duckduckgo.privacy.config.impl.PrivacyConfigDownloader.ConfigDownloadResult.Error +import com.duckduckgo.privacy.config.impl.PrivacyConfigDownloader.ConfigDownloadResult.Success import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.hamcrest.CoreMatchers.`is` diff --git a/privacy-config/privacy-config-internal/src/main/java/com/duckduckgo/privacy/config/internal/PrivacyConfigInternalViewModel.kt b/privacy-config/privacy-config-internal/src/main/java/com/duckduckgo/privacy/config/internal/PrivacyConfigInternalViewModel.kt index 44339ceafe50..cb5723c083a7 100644 --- a/privacy-config/privacy-config-internal/src/main/java/com/duckduckgo/privacy/config/internal/PrivacyConfigInternalViewModel.kt +++ b/privacy-config/privacy-config-internal/src/main/java/com/duckduckgo/privacy/config/internal/PrivacyConfigInternalViewModel.kt @@ -23,9 +23,9 @@ import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope import com.duckduckgo.privacy.config.api.PRIVACY_REMOTE_CONFIG_URL -import com.duckduckgo.privacy.config.impl.ConfigDownloadResult.Error -import com.duckduckgo.privacy.config.impl.ConfigDownloadResult.Success import com.duckduckgo.privacy.config.impl.PrivacyConfigDownloader +import com.duckduckgo.privacy.config.impl.PrivacyConfigDownloader.ConfigDownloadResult.Error +import com.duckduckgo.privacy.config.impl.PrivacyConfigDownloader.ConfigDownloadResult.Success import com.duckduckgo.privacy.config.internal.PrivacyConfigInternalViewModel.Command.ConfigDownloaded import com.duckduckgo.privacy.config.internal.PrivacyConfigInternalViewModel.Command.ConfigError import com.duckduckgo.privacy.config.internal.PrivacyConfigInternalViewModel.Command.Loading diff --git a/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/AppRemoteMessagingRepository.kt b/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/AppRemoteMessagingRepository.kt index 20f32ed0dd4c..ca953bf010a5 100644 --- a/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/AppRemoteMessagingRepository.kt +++ b/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/AppRemoteMessagingRepository.kt @@ -16,9 +16,6 @@ package com.duckduckgo.remote.messaging.impl -import com.duckduckgo.app.statistics.VariantManager -import com.duckduckgo.app.statistics.isAskForDefaultBrowserMoreThanOnceExperimentEnabled -import com.duckduckgo.browser.api.UserBrowserProperties import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.remote.messaging.api.RemoteMessage import com.duckduckgo.remote.messaging.api.RemoteMessagingRepository @@ -38,8 +35,6 @@ class AppRemoteMessagingRepository( private val remoteMessagesDao: RemoteMessagesDao, private val dispatchers: DispatcherProvider, private val messageMapper: MessageMapper, - private val userBrowserProperties: UserBrowserProperties, - private val variantManager: VariantManager, ) : RemoteMessagingRepository { override fun activeMessage(message: RemoteMessage?) { @@ -59,16 +54,7 @@ class AppRemoteMessagingRepository( } override fun messageFlow(): Flow { - // Experiment: Ask for Default Browser More Than Once - val daysSinceInstalled = userBrowserProperties.daysSinceInstalled() - val isDefaultBrowser = userBrowserProperties.defaultBrowser() - val isDefaultBrowserExperiment = variantManager.isAskForDefaultBrowserMoreThanOnceExperimentEnabled() return remoteMessagesDao.messagesFlow().distinctUntilChanged().map { - if ( - isDefaultBrowserExperiment && (daysSinceInstalled != 0L && daysSinceInstalled != 1L && daysSinceInstalled != 2L || isDefaultBrowser) - ) { - return@map null - } if (it == null || it.message.isEmpty()) return@map null val message = messageMapper.fromMessage(it.message) ?: return@map null diff --git a/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/di/RemoteMessagingModule.kt b/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/di/RemoteMessagingModule.kt index a6b8b4b8b41b..2c547b825536 100644 --- a/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/di/RemoteMessagingModule.kt +++ b/remote-messaging/remote-messaging-impl/src/main/java/com/duckduckgo/remote/messaging/impl/di/RemoteMessagingModule.kt @@ -18,7 +18,6 @@ package com.duckduckgo.remote.messaging.impl.di import android.content.Context import androidx.room.Room -import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.browser.api.AppProperties import com.duckduckgo.browser.api.UserBrowserProperties @@ -29,8 +28,6 @@ import com.duckduckgo.remote.messaging.api.AttributeMatcherPlugin import com.duckduckgo.remote.messaging.api.MessageActionMapperPlugin import com.duckduckgo.remote.messaging.api.RemoteMessagingRepository import com.duckduckgo.remote.messaging.impl.* -import com.duckduckgo.remote.messaging.impl.RealRemoteMessagingConfigDownloader -import com.duckduckgo.remote.messaging.impl.RemoteMessagingConfigDownloader import com.duckduckgo.remote.messaging.impl.mappers.MessageMapper import com.duckduckgo.remote.messaging.impl.mappers.RemoteMessagingConfigJsonMapper import com.duckduckgo.remote.messaging.impl.matchers.AndroidAppAttributeMatcher @@ -87,16 +84,12 @@ object DataSourceModule { remoteMessagesDao: RemoteMessagesDao, dispatchers: DispatcherProvider, messageMapper: MessageMapper, - userBrowserProperties: UserBrowserProperties, - variantManager: VariantManager, ): RemoteMessagingRepository { return AppRemoteMessagingRepository( remoteMessagingConfigRepository, remoteMessagesDao, dispatchers, messageMapper, - userBrowserProperties, - variantManager, ) } diff --git a/remote-messaging/remote-messaging-impl/src/test/java/com/duckduckgo/remote/messaging/impl/AppRemoteMessagingRepositoryTest.kt b/remote-messaging/remote-messaging-impl/src/test/java/com/duckduckgo/remote/messaging/impl/AppRemoteMessagingRepositoryTest.kt index 053091bbfb8c..3c707ea242f6 100644 --- a/remote-messaging/remote-messaging-impl/src/test/java/com/duckduckgo/remote/messaging/impl/AppRemoteMessagingRepositoryTest.kt +++ b/remote-messaging/remote-messaging-impl/src/test/java/com/duckduckgo/remote/messaging/impl/AppRemoteMessagingRepositoryTest.kt @@ -20,9 +20,6 @@ import androidx.room.Room import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import app.cash.turbine.test -import com.duckduckgo.app.statistics.Variant -import com.duckduckgo.app.statistics.VariantManager -import com.duckduckgo.browser.api.UserBrowserProperties import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.remote.messaging.api.Action import com.duckduckgo.remote.messaging.api.Action.Share @@ -45,13 +42,10 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue -import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.mockito.kotlin.any import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever // TODO: when pattern established, refactor objects to use (create module https://app.asana.com/0/0/1201807285420697/f) @ExperimentalCoroutinesApi @@ -72,27 +66,13 @@ class AppRemoteMessagingRepositoryTest { private val remoteMessagingConfigRepository: RemoteMessagingConfigRepository = mock() - private val userBrowserProperties: UserBrowserProperties = mock() - - private val variantManager: VariantManager = mock() - - private val variant: Variant = mock() - private val testee = AppRemoteMessagingRepository( remoteMessagingConfigRepository, dao, coroutineRule.testDispatcherProvider, getMessageMapper(), - userBrowserProperties, - variantManager, ) - @Before - fun before() { - whenever(variant.hasFeature(any())).thenReturn(false) - whenever(variantManager.getVariant()).thenReturn(variant) - } - @After fun after() { db.close() diff --git a/statistics/build.gradle b/statistics/build.gradle index 17ae6f6415bd..7cb334ebe6ac 100644 --- a/statistics/build.gradle +++ b/statistics/build.gradle @@ -32,6 +32,8 @@ dependencies { implementation project(path: ':app-build-config-api') implementation project(path: ':browser-api') implementation project(path: ':autofill-api') + implementation project(path: ':experiments-api') + implementation project(path: ':privacy-config-api') implementation Kotlin.stdlib.jdk7 implementation KotlinX.coroutines.core @@ -59,9 +61,6 @@ dependencies { implementation AndroidX.core.ktx - // Apache commons - implementation "org.apache.commons:commons-math3:_" - implementation "com.jakewharton.threetenabp:threetenabp:_" testImplementation "org.threeten:threetenbp:_" diff --git a/statistics/src/main/java/com/duckduckgo/app/statistics/AtbInitializer.kt b/statistics/src/main/java/com/duckduckgo/app/statistics/AtbInitializer.kt index dabf827766b9..c5fd83b6efcd 100644 --- a/statistics/src/main/java/com/duckduckgo/app/statistics/AtbInitializer.kt +++ b/statistics/src/main/java/com/duckduckgo/app/statistics/AtbInitializer.kt @@ -23,6 +23,12 @@ 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.di.scopes.AppScope +import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.SingleInstanceIn +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull @@ -37,13 +43,22 @@ interface AtbInitializerListener { fun beforeAtbInitTimeoutMillis(): Long } -class AtbInitializer( +@ContributesMultibinding( + scope = AppScope::class, + boundType = MainProcessLifecycleObserver::class, +) +@ContributesMultibinding( + scope = AppScope::class, + boundType = PrivacyConfigCallbackPlugin::class, +) +@SingleInstanceIn(AppScope::class) +class AtbInitializer @Inject constructor( @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val statisticsDataStore: StatisticsDataStore, private val statisticsUpdater: StatisticsUpdater, - private val listeners: Set, + private val listeners: DaggerSet, private val dispatcherProvider: DispatcherProvider, -) : MainProcessLifecycleObserver { +) : MainProcessLifecycleObserver, PrivacyConfigCallbackPlugin { override fun onResume(owner: LifecycleOwner) { appCoroutineScope.launch(dispatcherProvider.io()) { initialize() } @@ -62,7 +77,12 @@ class AtbInitializer( private fun initializeAtb() { if (statisticsDataStore.hasInstallationStatistics) { statisticsUpdater.refreshAppRetentionAtb() - } else { + } + } + + override fun onPrivacyConfigDownloaded() { + if (!statisticsDataStore.hasInstallationStatistics) { + // First time we initializeAtb statisticsUpdater.initializeAtb() } } diff --git a/statistics/src/main/java/com/duckduckgo/app/statistics/VariantManager.kt b/statistics/src/main/java/com/duckduckgo/app/statistics/VariantManager.kt deleted file mode 100644 index 1b53226e8fdf..000000000000 --- a/statistics/src/main/java/com/duckduckgo/app/statistics/VariantManager.kt +++ /dev/null @@ -1,215 +0,0 @@ -/* - * Copyright (c) 2018 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.app.statistics - -import androidx.annotation.WorkerThread -import com.duckduckgo.app.statistics.VariantManager.Companion.DEFAULT_VARIANT -import com.duckduckgo.app.statistics.VariantManager.Companion.referrerVariant -import com.duckduckgo.app.statistics.VariantManager.VariantFeature.AskForDefaultBrowserMoreThanOnce -import com.duckduckgo.app.statistics.store.StatisticsDataStore -import com.duckduckgo.appbuildconfig.api.AppBuildConfig -import java.util.Locale -import timber.log.Timber - -@WorkerThread -interface VariantManager { - - // variant-dependant features listed here - sealed class VariantFeature { - object AskForDefaultBrowserMoreThanOnce : VariantFeature() - } - - companion object { - - const val RESERVED_EU_AUCTION_VARIANT = "ml" - - // this will be returned when there are no other active experiments - val DEFAULT_VARIANT = Variant(key = "", features = emptyList(), filterBy = { noFilter() }) - - val ACTIVE_VARIANTS = listOf( - // SERP variants. "sc" may also be used as a shared control for mobile experiments in - // the future if we can filter by app version - Variant(key = "sc", weight = 0.0, features = emptyList(), filterBy = { isSerpRegionToggleCountry() }), - Variant(key = "se", weight = 0.0, features = emptyList(), filterBy = { isSerpRegionToggleCountry() }), - - // Experiment: Ask for Default Browser More Than Once - Variant(key = "zh", weight = 1.0, features = emptyList(), filterBy = { isDefaultBrowserExperimentCountry() }), - Variant( - key = "zj", - weight = 1.0, - features = listOf(AskForDefaultBrowserMoreThanOnce), - filterBy = { isDefaultBrowserExperimentCountry() }, - ), - ) - - val REFERRER_VARIANTS = listOf( - Variant(RESERVED_EU_AUCTION_VARIANT, features = emptyList(), filterBy = { noFilter() }), - ) - - private val defaultBrowserExperimentCountries = listOf( - "US", - "CA", - "GB", - "AU", - ) - - private val serpRegionToggleTargetCountries = listOf( - "AU", - "AT", - "DK", - "FI", - "FR", - "DE", - "IT", - "IE", - "NZ", - "NO", - "ES", - "SE", - "GB", - ) - - fun referrerVariant(key: String): Variant { - val knownReferrer = REFERRER_VARIANTS.firstOrNull { it.key == key } - return knownReferrer ?: Variant(key, features = emptyList(), filterBy = { noFilter() }) - } - - private fun noFilter(): Boolean = true - - private fun isEnglishLocale(): Boolean { - val locale = Locale.getDefault() - return locale != null && locale.language == "en" - } - - private fun isDefaultBrowserExperimentCountry(): Boolean { - val locale = Locale.getDefault() - return locale != null && locale.country in defaultBrowserExperimentCountries && locale.language == "en" - } - - private fun isSerpRegionToggleCountry(): Boolean { - val locale = Locale.getDefault() - return locale != null && serpRegionToggleTargetCountries.contains(locale.country) - } - } - - fun getVariant(activeVariants: List = ACTIVE_VARIANTS): Variant - - fun updateAppReferrerVariant(variant: String) -} - -class ExperimentationVariantManager( - private val store: StatisticsDataStore, - private val indexRandomizer: IndexRandomizer, - private val appBuildConfig: AppBuildConfig, -) : VariantManager { - - @Synchronized - override fun getVariant(activeVariants: List): Variant { - val currentVariantKey = store.variant - - if (currentVariantKey == DEFAULT_VARIANT.key) { - return DEFAULT_VARIANT - } - - if (currentVariantKey != null && matchesReferrerVariant(currentVariantKey)) { - return referrerVariant(currentVariantKey) - } - - if (currentVariantKey == null || activeVariants.isEmpty()) { - return allocateNewVariant(activeVariants) - } - - val currentVariant = lookupVariant(currentVariantKey, activeVariants) - if (currentVariant == null) { - Timber.i("Variant $currentVariantKey no longer an active variant; user will now use default variant") - val newVariant = DEFAULT_VARIANT - persistVariant(newVariant) - return newVariant - } - - return currentVariant - } - - private fun allocateNewVariant(activeVariants: List): Variant { - var newVariant = generateVariant(activeVariants) - val compliesWithFilters = newVariant.filterBy(appBuildConfig) - - if (!compliesWithFilters || appBuildConfig.isDefaultVariantForced) { - newVariant = DEFAULT_VARIANT - } - Timber.i("Current variant is null; allocating new one $newVariant") - persistVariant(newVariant) - return newVariant - } - - override fun updateAppReferrerVariant(variant: String) { - Timber.i("Updating variant for app referer: $variant") - store.variant = variant - store.referrerVariant = variant - } - - private fun lookupVariant( - key: String?, - activeVariants: List, - ): Variant? { - val variant = activeVariants.firstOrNull { it.key == key } - - if (variant != null) return variant - - if (key != null && matchesReferrerVariant(key)) { - return referrerVariant(key) - } - - return null - } - - private fun persistVariant(newVariant: Variant) { - store.variant = newVariant.key - } - - private fun matchesReferrerVariant(key: String): Boolean { - return key == store.referrerVariant - } - - private fun generateVariant(activeVariants: List): Variant { - val weightSum = activeVariants.sumByDouble { it.weight } - if (weightSum == 0.0) { - Timber.v("No variants active; allocating default") - return DEFAULT_VARIANT - } - val randomizedIndex = indexRandomizer.random(activeVariants) - return activeVariants[randomizedIndex] - } -} - -fun VariantManager.isAskForDefaultBrowserMoreThanOnceExperimentEnabled() = - this.getVariant().hasFeature(AskForDefaultBrowserMoreThanOnce) - -/** - * A variant which can be used for experimentation. - * @param weight Relative weight. These are normalised to all other variants, so they don't have to add up to any specific number. - * - */ -data class Variant( - val key: String, - override val weight: Double = 0.0, - val features: List = emptyList(), - val filterBy: (config: AppBuildConfig) -> Boolean, -) : Probabilistic { - - fun hasFeature(feature: VariantManager.VariantFeature) = features.contains(feature) -} diff --git a/statistics/src/main/java/com/duckduckgo/app/statistics/api/PixelSender.kt b/statistics/src/main/java/com/duckduckgo/app/statistics/api/PixelSender.kt index 36fe12e8b262..f0b6a1405f70 100644 --- a/statistics/src/main/java/com/duckduckgo/app/statistics/api/PixelSender.kt +++ b/statistics/src/main/java/com/duckduckgo/app/statistics/api/PixelSender.kt @@ -18,7 +18,6 @@ package com.duckduckgo.app.statistics.api import androidx.lifecycle.LifecycleOwner import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver -import com.duckduckgo.app.statistics.VariantManager import com.duckduckgo.app.statistics.config.StatisticsLibraryConfig import com.duckduckgo.app.statistics.model.PixelEntity import com.duckduckgo.app.statistics.pixels.Pixel @@ -26,6 +25,7 @@ import com.duckduckgo.app.statistics.store.PendingPixelDao import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.common.utils.device.DeviceInfo import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.experiments.api.VariantManager import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesMultibinding import dagger.SingleInstanceIn @@ -160,7 +160,7 @@ class RxPixelSender @Inject constructor( return defaultParameters.plus(parameters) } - private fun getAtbInfo() = statisticsDataStore.atb?.formatWithVariant(variantManager.getVariant()) ?: "" + private fun getAtbInfo() = statisticsDataStore.atb?.formatWithVariant(variantManager.getVariantKey()) ?: "" private fun getDeviceFactor() = deviceInfo.formFactor().description } diff --git a/statistics/src/main/java/com/duckduckgo/app/statistics/api/StatisticsRequester.kt b/statistics/src/main/java/com/duckduckgo/app/statistics/api/StatisticsRequester.kt index c328dbc7a6fe..99681bc49342 100644 --- a/statistics/src/main/java/com/duckduckgo/app/statistics/api/StatisticsRequester.kt +++ b/statistics/src/main/java/com/duckduckgo/app/statistics/api/StatisticsRequester.kt @@ -17,15 +17,19 @@ package com.duckduckgo.app.statistics.api import android.annotation.SuppressLint -import com.duckduckgo.app.statistics.VariantManager +import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.statistics.model.Atb import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.duckduckgo.autofill.api.email.EmailManager +import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.experiments.api.VariantManager import com.squareup.anvil.annotations.ContributesBinding import io.reactivex.schedulers.Schedulers import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import timber.log.Timber interface StatisticsUpdater { @@ -41,6 +45,8 @@ class StatisticsRequester @Inject constructor( private val variantManager: VariantManager, private val plugins: PluginPoint, private val emailManager: EmailManager, + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatchers: DispatcherProvider, ) : StatisticsUpdater { /** @@ -60,7 +66,7 @@ class StatisticsRequester @Inject constructor( "Previous app version stored hardcoded `ma` variant in ATB param; we want to correct this behaviour", ) store.atb = Atb(storedAtb.version.removeSuffix(LEGACY_ATB_FORMAT_SUFFIX)) - store.variant = VariantManager.DEFAULT_VARIANT.key + store.variant = variantManager.defaultVariantKey() } return } @@ -74,7 +80,7 @@ class StatisticsRequester @Inject constructor( val atb = Atb(it.version) Timber.i("$atb") store.saveAtb(atb) - val atbWithVariant = atb.formatWithVariant(variantManager.getVariant()) + val atbWithVariant = atb.formatWithVariant(variantManager.getVariantKey()) Timber.i("Initialized ATB: $atbWithVariant") service.exti(atbWithVariant) @@ -100,25 +106,27 @@ class StatisticsRequester @Inject constructor( return } - val fullAtb = atb.formatWithVariant(variantManager.getVariant()) - val retentionAtb = store.searchRetentionAtb ?: atb.version + appCoroutineScope.launch(dispatchers.io()) { + val fullAtb = atb.formatWithVariant(variantManager.getVariantKey()) + val retentionAtb = store.searchRetentionAtb ?: atb.version - service - .updateSearchAtb( - atb = fullAtb, - retentionAtb = retentionAtb, - email = emailSignInState(), - ) - .subscribeOn(Schedulers.io()) - .subscribe( - { - Timber.v("Search atb refresh succeeded, latest atb is ${it.version}") - store.searchRetentionAtb = it.version - storeUpdateVersionIfPresent(it) - plugins.getPlugins().forEach { plugin -> plugin.onSearchRetentionAtbRefreshed() } - }, - { Timber.v("Search atb refresh failed with error ${it.localizedMessage}") }, - ) + service + .updateSearchAtb( + atb = fullAtb, + retentionAtb = retentionAtb, + email = emailSignInState(), + ) + .subscribeOn(Schedulers.io()) + .subscribe( + { + Timber.v("Search atb refresh succeeded, latest atb is ${it.version}") + store.searchRetentionAtb = it.version + storeUpdateVersionIfPresent(it) + plugins.getPlugins().forEach { plugin -> plugin.onSearchRetentionAtbRefreshed() } + }, + { Timber.v("Search atb refresh failed with error ${it.localizedMessage}") }, + ) + } } @SuppressLint("CheckResult") @@ -130,7 +138,7 @@ class StatisticsRequester @Inject constructor( return } - val fullAtb = atb.formatWithVariant(variantManager.getVariant()) + val fullAtb = atb.formatWithVariant(variantManager.getVariantKey()) val retentionAtb = store.appRetentionAtb ?: atb.version service diff --git a/statistics/src/main/java/com/duckduckgo/app/statistics/model/Atb.kt b/statistics/src/main/java/com/duckduckgo/app/statistics/model/Atb.kt index 682e96028813..53136d9d1822 100644 --- a/statistics/src/main/java/com/duckduckgo/app/statistics/model/Atb.kt +++ b/statistics/src/main/java/com/duckduckgo/app/statistics/model/Atb.kt @@ -16,14 +16,12 @@ package com.duckduckgo.app.statistics.model -import com.duckduckgo.app.statistics.Variant - data class Atb( val version: String, val updateVersion: String? = null, ) { - fun formatWithVariant(variant: Variant): String { - return version + variant.key + fun formatWithVariant(variantKey: String?): String { + return version + variantKey.orEmpty() } }