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 f9fcdb1e44f3..721577b24b82 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 @@ -306,6 +306,8 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { .addFunction(createParseJsonFun(module)) .addFunction(createParseExceptions(module)) .addFunction(createInvokeMethod(boundType)) + .addType(createJsonRolloutDataClass(generatedPackage, module)) + .addType(createJsonRolloutStepDataClass(generatedPackage, module)) .addType(createJsonToggleDataClass(generatedPackage, module)) .addType(createJsonFeatureDataClass(generatedPackage, module)) .addType(createJsonExceptionDataClass(generatedPackage, module)) @@ -408,16 +410,31 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { exceptionStore.insertAll(exceptions) val isEnabled = (feature.state == "enabled") || (appBuildConfig.flavor == %T && feature.state == "internal") - this.feature.get().invokeMethod("self").setEnabled(Toggle.State(isEnabled, feature.minSupportedVersion)) + this.feature.get().invokeMethod("self").setEnabled( + Toggle.State( + remoteEnableState = isEnabled, + enable = isEnabled, + minSupportedVersion = feature.minSupportedVersion, + ) + ) // Handle sub-features feature.features?.forEach { subfeature -> - subfeature.value.let { jsonObject -> - val jsonToggle = JsonToggle(jsonObject) + subfeature.value.let { jsonToggle -> + val previousState = this.feature.get().invokeMethod(subfeature.key).getRawStoredState() + // we try to honour the previous state + // else we resort to compute it using isEnabled() + val previousStateValue = previousState?.enable ?: this.feature.get().invokeMethod(subfeature.key).isEnabled() + + val previousRolloutStep = previousState?.rolloutStep + val newStateValue = (jsonToggle.state == "enabled" || (appBuildConfig.flavor == %T && jsonToggle.state == "internal")) this.feature.get().invokeMethod(subfeature.key).setEnabled( Toggle.State( - enable = jsonToggle.state == "enabled" || (appBuildConfig.flavor == %T && jsonToggle.state == "internal"), + remoteEnableState = newStateValue, + enable = previousStateValue, minSupportedVersion = jsonToggle.minSupportedVersion?.toInt(), + rollout = jsonToggle?.rollout?.steps?.map { it.percent }, + rolloutStep = previousRolloutStep, ), ) } @@ -548,51 +565,86 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { .build() } - private fun createJsonToggleDataClass( + private fun createJsonRolloutDataClass( generatedPackage: String, module: ModuleDescriptor, ): TypeSpec { - return TypeSpec.classBuilder(FqName("$generatedPackage.JsonToggle").asClassName(module)) + return TypeSpec.classBuilder(FqName("$generatedPackage.JsonToggleRollout").asClassName(module)) .addModifiers(KModifier.PRIVATE) .addModifiers(KModifier.DATA) .primaryConstructor( FunSpec.constructorBuilder() .addParameter( - "map", - Map::class.asClassName().parameterizedBy(String::class.asClassName(), Any::class.asClassName().copy(nullable = true)), + "steps", + List::class.asClassName().parameterizedBy(FqName("JsonToggleRolloutStep").asClassName(module)), ) .build(), ) - // Property map that matches params to generate data class .addProperty( - PropertySpec - .builder( - "map", - Map::class.asClassName().parameterizedBy(String::class.asClassName(), Any::class.asClassName().copy(nullable = true)), - ) - .initializer("map") + PropertySpec.builder( + "steps", + List::class.asClassName().parameterizedBy(FqName("JsonToggleRolloutStep").asClassName(module)), + ).initializer("steps") .build(), ) - .addProperty( - PropertySpec - .builder( - "attributes", - Map::class.asClassName().parameterizedBy(String::class.asClassName(), Any::class.asClassName().copy(nullable = true)), + .build() + } + + private fun createJsonRolloutStepDataClass( + generatedPackage: String, + module: ModuleDescriptor, + ): TypeSpec { + return TypeSpec.classBuilder(FqName("$generatedPackage.JsonToggleRolloutStep").asClassName(module)) + .addModifiers(KModifier.PRIVATE) + .addModifiers(KModifier.DATA) + .primaryConstructor( + FunSpec.constructorBuilder() + .addParameter("percent", Double::class.asClassName()) + .build(), + ) + .addProperty(PropertySpec.builder("percent", Double::class.asClassName()).initializer("percent").build()) + .build() + } + + private fun createJsonToggleDataClass( + generatedPackage: String, + module: ModuleDescriptor, + ): TypeSpec { + return TypeSpec.classBuilder(FqName("$generatedPackage.JsonToggle").asClassName(module)) + .addModifiers(KModifier.PRIVATE) + .addModifiers(KModifier.DATA) + .primaryConstructor( + FunSpec.constructorBuilder() + .addParameter( + "state", + String::class.asClassName().copy(nullable = true), + ) + .addParameter( + "minSupportedVersion", + Double::class.asClassName().copy(nullable = true), + ) + .addParameter( + "rollout", + FqName("JsonToggleRollout").asClassName(module).copy(nullable = true), ) - .addModifiers(KModifier.PRIVATE) - .initializer(CodeBlock.of("map.withDefault { null }")) .build(), ) .addProperty( PropertySpec .builder("state", String::class.asClassName().copy(nullable = true)) - .delegate("attributes") + .initializer("state") .build(), ) .addProperty( PropertySpec .builder("minSupportedVersion", Double::class.asClassName().copy(nullable = true)) - .delegate("attributes") + .initializer("minSupportedVersion") + .build(), + ) + .addProperty( + PropertySpec + .builder("rollout", FqName("JsonToggleRollout").asClassName(module).copy(nullable = true)) + .initializer("rollout") .build(), ) .build() @@ -625,10 +677,7 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { "features", Map::class.asClassName().parameterizedBy( String::class.asClassName(), - Map::class.asClassName().parameterizedBy( - String::class.asClassName(), - Any::class.asClassName().copy(nullable = true), - ), + FqName("JsonToggle").asClassName(module), ).copy(nullable = true), ) .build(), @@ -661,7 +710,7 @@ class ContributesRemoteFeatureCodeGenerator : CodeGenerator { "features", Map::class.asClassName().parameterizedBy( String::class.asClassName(), - Map::class.asClassName().parameterizedBy(String::class.asClassName(), Any::class.asClassName().copy(nullable = true)), + FqName("JsonToggle").asClassName(module), ).copy(nullable = true), ) .initializer("features") diff --git a/app/src/androidTest/java/com/duckduckgo/app/email/EmailInjectorJsTest.kt b/app/src/androidTest/java/com/duckduckgo/app/email/EmailInjectorJsTest.kt index e96465d01814..505ce97e0db6 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/email/EmailInjectorJsTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/email/EmailInjectorJsTest.kt @@ -28,6 +28,7 @@ import com.duckduckgo.app.global.DispatcherProvider import com.duckduckgo.autofill.api.Autofill import com.duckduckgo.autofill.api.AutofillFeature import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.State import java.io.BufferedReader import org.junit.Before import org.junit.Test @@ -64,6 +65,8 @@ class EmailInjectorJsTest { override fun setEnabled(state: Toggle.State) { this.state = state } + + override fun getRawStoredState(): State? = this.state }, ) whenever(mockAutofill.isAnException(any())).thenReturn(false) diff --git a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillCapabilityCheckerImplTest.kt b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillCapabilityCheckerImplTest.kt index 75852dcdcf76..15c8bdd499f3 100644 --- a/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillCapabilityCheckerImplTest.kt +++ b/autofill/autofill-impl/src/test/java/com/duckduckgo/autofill/impl/AutofillCapabilityCheckerImplTest.kt @@ -168,6 +168,9 @@ class AutofillCapabilityCheckerImplTest { return object : Toggle { override fun isEnabled(): Boolean = topLevelFeatureEnabled override fun setEnabled(state: State) {} + override fun getRawStoredState(): State? { + TODO("Not yet implemented") + } } } @@ -175,6 +178,9 @@ class AutofillCapabilityCheckerImplTest { return object : Toggle { override fun isEnabled(): Boolean = canInjectCredentials override fun setEnabled(state: State) {} + override fun getRawStoredState(): State? { + TODO("Not yet implemented") + } } } @@ -182,6 +188,9 @@ class AutofillCapabilityCheckerImplTest { return object : Toggle { override fun isEnabled(): Boolean = canSaveCredentials override fun setEnabled(state: State) {} + override fun getRawStoredState(): State? { + TODO("Not yet implemented") + } } } @@ -189,6 +198,9 @@ class AutofillCapabilityCheckerImplTest { return object : Toggle { override fun isEnabled(): Boolean = canGeneratePassword override fun setEnabled(state: State) {} + override fun getRawStoredState(): State? { + TODO("Not yet implemented") + } } } @@ -196,6 +208,9 @@ class AutofillCapabilityCheckerImplTest { return object : Toggle { override fun isEnabled(): Boolean = canAccessCredentialManagement override fun setEnabled(state: State) {} + override fun getRawStoredState(): State? { + TODO("Not yet implemented") + } } } } diff --git a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScriptsTest.kt b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScriptsTest.kt index d8952f4debf7..355fa1b25cad 100644 --- a/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScriptsTest.kt +++ b/content-scope-scripts/content-scope-scripts-impl/src/test/java/com/duckduckgo/contentscopescripts/impl/RealContentScopeScriptsTest.kt @@ -214,6 +214,8 @@ class RealContentScopeScriptsTest { override fun setEnabled(state: State) { // not implemented } + + override fun getRawStoredState(): State? = null } class DisabledToggle : Toggle { @@ -224,6 +226,8 @@ class RealContentScopeScriptsTest { override fun setEnabled(state: State) { // not implemented } + + override fun getRawStoredState(): State? = null } companion object { 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 2ea0e9c81586..0c8c7f3b8faa 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 @@ -16,10 +16,12 @@ package com.duckduckgo.feature.toggles.api +import com.duckduckgo.feature.toggles.api.Toggle.State import java.lang.IllegalArgumentException import java.lang.IllegalStateException import java.lang.reflect.Method import java.lang.reflect.Proxy +import kotlin.random.Random class FeatureToggles private constructor( private val store: Toggle.Store, @@ -108,14 +110,35 @@ class FeatureToggles private constructor( } interface Toggle { + /** + * This is the method that SHALL be called to get whether a feature is enabled or not. DO NOT USE [getRawStoredState] for that + * @return `true` if the feature should be enabled, `false` otherwise + */ fun isEnabled(): Boolean + /** + * The usage of this API is only useful for internal/dev settings/features + * If you find yourself having to call this method in production code, then YOUR DOING SOMETHING WRONG + * + * @param state update the stored [State] of the feature flag + */ fun setEnabled(state: State) + /** + * The usage of this API is only useful for internal/dev settings/features + * If you find yourself having to call this method in production code, then YOUR DOING SOMETHING WRONG + * + * @return the raw [State] store for this feature flag. + */ + fun getRawStoredState(): State? + data class State( + val remoteEnableState: Boolean? = null, val enable: Boolean = false, val minSupportedVersion: Int? = null, val enabledOverrideValue: Boolean? = null, + val rollout: List? = null, + val rolloutStep: Int? = null, ) interface Store { @@ -138,12 +161,86 @@ internal class ToggleImpl constructor( private val appVersionProvider: () -> Int, ) : Toggle { override fun isEnabled(): Boolean { - store.get(key)?.let { state -> - return state.enable && appVersionProvider.invoke() >= (state.minSupportedVersion ?: 0) + return store.get(key)?.let { state -> + state.remoteEnableState?.let { remoteState -> + remoteState && state.enable && appVersionProvider.invoke() >= (state.minSupportedVersion ?: 0) + } ?: defaultValue } ?: return defaultValue } + @Suppress("NAME_SHADOWING") override fun setEnabled(state: Toggle.State) { + var state = state + + // remote is disabled, store and skip everything + if (state.remoteEnableState == false) { + store.set(key, state) + return + } + + // local state is false (and remote state is enabled) try incremental rollout + if (!state.enable) { + state = calculateRolloutState(state) + } + + // remote state is null, means app update. Propagate the local state to remote state + if (state.remoteEnableState == null) { + state = state.copy(remoteEnableState = state.enable) + } + + // finally store the state store.set(key, state) } + + override fun getRawStoredState(): State? { + return store.get(key) + } + + private fun calculateRolloutState( + state: State, + ): State { + fun sample(probability: Double): Boolean { + val random = Random.nextDouble(100.0) + return random < probability + } + val rolloutStep = state.rolloutStep + + // there is no rollout, return whatever the previous state was + if (state.rollout.isNullOrEmpty()) return state + + val sortedRollout = state.rollout.sorted().filter { it in 0.0..100.0 } + if (sortedRollout.isEmpty()) return state + + when (rolloutStep) { + // first time we see the rollout, pick the last step + null -> { + val step = sortedRollout.last() + val isEnabled = sample(step.toDouble()) + return state.copy( + enable = isEnabled, + rolloutStep = sortedRollout.size, + ) + } + // this is an error and should not happen, don't change state + 0 -> { + return state + } + else -> { + val steps = sortedRollout.size + val lastStep = state.rolloutStep + + for (s in lastStep until steps) { + // determine effective probability + val probability = (sortedRollout[s] - sortedRollout[s - 1]) / (100.0 - sortedRollout[s - 1]) + if (sample(probability * 100.0)) { + return state.copy( + enable = true, + rolloutStep = s + 1, + ) + } + } + return state.copy(rolloutStep = sortedRollout.size) + } + } + } } diff --git a/feature-toggles/feature-toggles-impl/build.gradle b/feature-toggles/feature-toggles-impl/build.gradle index 30a7c7c848d6..226b11a26009 100644 --- a/feature-toggles/feature-toggles-impl/build.gradle +++ b/feature-toggles/feature-toggles-impl/build.gradle @@ -23,12 +23,12 @@ plugins { apply from: "$rootProject.projectDir/gradle/android-library.gradle" dependencies { - anvil project(path: ':anvil-compiler') - implementation project(path: ':anvil-annotations') + anvil project(':anvil-compiler') + implementation project(':anvil-annotations') - implementation project(path: ':di') - implementation project(path: ':common-utils') - implementation project(path: ':feature-toggles-api') + implementation project(':di') + implementation project(':common-utils') + implementation project(':feature-toggles-api') implementation Kotlin.stdlib.jdk7 implementation AndroidX.appCompat @@ -39,8 +39,14 @@ dependencies { implementation AndroidX.core.ktx // Testing dependencies - testImplementation project(path: ':feature-toggles-test') - testImplementation project(path: ':common-test') + testImplementation project(':feature-toggles-test') + testImplementation project(':common-test') + testImplementation project(':app-build-config-api') + testImplementation project(':privacy-config-api') + testImplementation "org.mockito.kotlin:mockito-kotlin:_" + testImplementation Testing.robolectric + testImplementation AndroidX.test.ext.junit + testImplementation Square.retrofit2.converter.moshi testImplementation Testing.junit4 } 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 a874408a9e69..944845436605 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 @@ -18,7 +18,10 @@ package com.duckduckgo.feature.toggles.api import java.lang.IllegalStateException import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -27,12 +30,14 @@ class FeatureTogglesTest { private lateinit var feature: TestFeature private lateinit var versionProvider: FakeAppVersionProvider + private lateinit var toggleStore: FakeToggleStore @Before fun setup() { versionProvider = FakeAppVersionProvider() + toggleStore = FakeToggleStore() feature = FeatureToggles.Builder() - .store(FakeToggleStore()) + .store(toggleStore) .appVersionProvider { versionProvider.version } .featureName("test") .build() @@ -114,6 +119,174 @@ class FeatureTogglesTest { .create(TestFeature::class.java) .self() } + + @Test + fun whenEnabledAndInvalidOrValidRolloutThenIsEnableReturnsTrue() { + val state = Toggle.State( + enable = true, + rollout = null, + rolloutStep = null, + ) + feature.self().setEnabled(state) + assertTrue(feature.self().isEnabled()) + + feature.self().setEnabled(state.copy(rollout = emptyList())) + assertTrue(feature.self().isEnabled()) + + feature.self().setEnabled(state.copy(rolloutStep = 2)) + assertTrue(feature.self().isEnabled()) + + feature.self().setEnabled(state.copy(rollout = listOf(1.0, 2.0))) + assertTrue(feature.self().isEnabled()) + + feature.self().setEnabled(state.copy(rollout = listOf(0.5, 2.0), rolloutStep = 0)) + assertTrue(feature.self().isEnabled()) + + feature.self().setEnabled(state.copy(rollout = listOf(0.5, 100.0), rolloutStep = 1)) + assertTrue(feature.self().isEnabled()) + } + + @Test + fun whenEnabledAndValidRolloutThenReturnKeepRolloutStep() { + val state = Toggle.State( + enable = true, + rollout = listOf(100.0), + rolloutStep = null, + ) + val expected = state.copy(remoteEnableState = state.enable) + feature.self().setEnabled(state) + assertTrue(feature.self().isEnabled()) + assertEquals(expected, toggleStore.get("test")) + } + + @Test + fun whenDisabledAndValidRolloutThenDetermineRolloutValue() { + val state = Toggle.State( + enable = false, + rollout = listOf(100.0), + rolloutStep = null, + ) + feature.self().setEnabled(state) + assertTrue(feature.self().isEnabled()) + + val updatedState = toggleStore.get("test") + assertEquals(1, updatedState?.rolloutStep) + assertTrue(updatedState!!.enable) + } + + @Test + fun whenDisabledAndValidRolloutWithMultipleStepsThenDetermineRolloutValue() { + val state = Toggle.State( + enable = false, + rollout = listOf(1.0, 10.0, 20.0, 40.0, 100.0), + rolloutStep = null, + ) + feature.self().setEnabled(state) + assertTrue(feature.self().isEnabled()) + + val updatedState = toggleStore.get("test") + assertEquals(5, updatedState?.rolloutStep) + assertTrue(updatedState!!.enable) + } + + @Test + fun whenDisabledWithPreviousStepsAndValidRolloutWithMultipleStepsThenDetermineRolloutValue() { + val state = Toggle.State( + enable = false, + rollout = listOf(1.0, 10.0, 20.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0), + rolloutStep = 2, + ) + feature.self().setEnabled(state) + assertTrue(feature.self().isEnabled()) + + val updatedState = toggleStore.get("test") + assertTrue(updatedState?.rolloutStep!! <= state.rollout!!.size && updatedState.rolloutStep!! > 2) + assertTrue(updatedState!!.enable) + } + + @Test + fun whenDisabledWithValidRolloutStepsAndNotSupportedVersionThenReturnDisabled() { + versionProvider.version = 10 + val state = Toggle.State( + enable = false, + rollout = listOf(1.0, 10.0, 20.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0), + rolloutStep = 2, + minSupportedVersion = 11, + ) + feature.self().setEnabled(state) + + // the feature flag is internally enabled but isEnabled() returns disabled because it doesn't meet minSupportedVersion + assertFalse(feature.self().isEnabled()) + val updatedState = toggleStore.get("test")!! + assertEquals(true, updatedState.enable) + assertNotEquals(2, updatedState.rolloutStep) + assertEquals(state.minSupportedVersion, updatedState.minSupportedVersion) + assertEquals(state.rollout, updatedState.rollout) + } + + @Test + fun whenRemoteEnableStateIsNullThenHonourLocalEnableStateAndUpdate() { + val state = Toggle.State(enable = false) + feature.self().setEnabled(state) + + assertFalse(feature.self().isEnabled()) + val updatedState = toggleStore.get("test")!! + assertEquals(false, updatedState.enable) + assertEquals(false, updatedState.remoteEnableState) + assertNull(updatedState.rolloutStep) + assertNull(updatedState.rollout) + } + + @Test + fun whenRemoteStateDisabledThenIgnoreLocalState() { + val state = Toggle.State( + remoteEnableState = false, + enable = true, + ) + feature.self().setEnabled(state) + assertFalse(feature.self().isEnabled()) + assertEquals(state, toggleStore.get("test")) + } + + @Test + fun whenRemoteStateDisabledAndValidRolloutThenIgnoreRollout() { + val state = Toggle.State( + remoteEnableState = false, + enable = true, + rollout = listOf(100.0), + rolloutStep = null, + ) + feature.self().setEnabled(state) + assertFalse(feature.self().isEnabled()) + assertEquals(state, toggleStore.get("test")) + } + + @Test + fun whenRemoteStateEnabledAndLocalStateEnabledWithValidRolloutThenIgnoreRollout() { + val state = Toggle.State( + remoteEnableState = true, + enable = true, + rollout = listOf(100.0), + rolloutStep = null, + ) + feature.self().setEnabled(state) + assertTrue(feature.self().isEnabled()) + assertEquals(state, toggleStore.get("test")) + } + + @Test + fun whenRemoteStateEnabledAndLocalStateDisabledWithValidRolloutThenDoRollout() { + val state = Toggle.State( + remoteEnableState = true, + enable = false, + rollout = listOf(100.0), + rolloutStep = null, + ) + val expected = state.copy(enable = true, rollout = listOf(100.0), rolloutStep = 1) + feature.self().setEnabled(state) + assertTrue(feature.self().isEnabled()) + assertEquals(expected, toggleStore.get("test")) + } } interface TestFeature { 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 new file mode 100644 index 000000000000..131ad01fc454 --- /dev/null +++ b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/ContributesRemoteFeatureCodeGeneratorTest.kt @@ -0,0 +1,843 @@ +/* + * 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.feature.toggles.codegen + +import android.content.Context +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.INTERNAL +import com.duckduckgo.feature.toggles.api.FakeToggleStore +import com.duckduckgo.feature.toggles.api.FeatureExceptions +import com.duckduckgo.feature.toggles.api.FeatureSettings +import com.duckduckgo.feature.toggles.api.FeatureToggles +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.privacy.config.api.PrivacyFeaturePlugin +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesMultibinding +import dagger.Lazy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +@RunWith(AndroidJUnit4::class) +class ContributesRemoteFeatureCodeGeneratorTest { + + private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext + private lateinit var testFeature: TestTriggerFeature + private val appBuildConfig: AppBuildConfig = mock() + private lateinit var versionProvider: FakeAppVersionProvider + + @Before + fun setup() { + versionProvider = FakeAppVersionProvider() + testFeature = FeatureToggles.Builder( + FakeToggleStore(), + featureName = "testFeature", + appVersionProvider = { versionProvider.version }, + ).build().create(TestTriggerFeature::class.java) + } + + @Test + fun `the class is generated`() { + val generatedClass = Class + .forName("com.duckduckgo.feature.toggles.codegen.TestTriggerFeature_RemoteFeature") + assertNotNull(generatedClass) + } + + @Test + fun `the class is generated implements Toggle Store and PrivacyFeaturePlugin`() { + val generatedClass = Class + .forName("com.duckduckgo.feature.toggles.codegen.TestTriggerFeature_RemoteFeature") + .kotlin + + assertEquals(2, generatedClass.java.interfaces.size) + assertTrue(generatedClass.java.interfaces.contains(Toggle.Store::class.java)) + assertTrue(generatedClass.java.interfaces.contains(PrivacyFeaturePlugin::class.java)) + } + + @Test + fun `the class factory is generated`() { + val generatedClass = Class + .forName("com.duckduckgo.feature.toggles.codegen.TestTriggerFeature_RemoteFeature_Factory") + assertNotNull(generatedClass) + } + + @Test + fun `the generated class contributes the toggle store binding`() { + val generatedClass = Class + .forName("com.duckduckgo.feature.toggles.codegen.TestTriggerFeature_RemoteFeature") + .kotlin + + val annotation = generatedClass.java.getAnnotation(ContributesBinding::class.java)!! + assertEquals(TriggerTestScope::class, annotation.scope) + assertEquals(Toggle.Store::class, annotation.boundType) + } + + @Test + fun `the generated class contributes the privacy plugin multibinding`() { + val generatedClass = Class + .forName("com.duckduckgo.feature.toggles.codegen.TestTriggerFeature_RemoteFeature") + .kotlin + + val annotation = generatedClass.java.getAnnotation(ContributesMultibinding::class.java)!! + assertEquals(TriggerTestScope::class, annotation.scope) + assertEquals(PrivacyFeaturePlugin::class, annotation.boundType) + assertTrue(annotation.ignoreQualifier) + } + + @Test + fun `the disable state of the feature always wins`() { + val feature = generatedFeatureNewInstance() + + val privacyPlugin = (feature as PrivacyFeaturePlugin) + + assertTrue( + privacyPlugin.store( + "testFeature", + """ + { + "state": "disabled", + "features": { + "fooFeature": { + "state": "disabled", + "rollout": { + "steps": [ + { + "percent": 0 + } + ] + } + } + } + } + """.trimIndent(), + ), + ) + assertFalse(testFeature.self().isEnabled()) + assertFalse(testFeature.fooFeature().isEnabled()) + + assertTrue( + privacyPlugin.store( + "testFeature", + """ + { + "state": "disabled", + "features": { + "fooFeature": { + "state": "disabled", + "rollout": { + "steps": [ + { + "percent": 100 + } + ] + } + } + } + } + """.trimIndent(), + ), + ) + assertFalse(testFeature.self().isEnabled()) + assertFalse(testFeature.fooFeature().isEnabled()) + } + + @Test + fun `the rollout step set to 0 disables the feature`() { + val jsonFeature = """ + { + "state": "enabled", + "features": { + "fooFeature": { + "state": "enabled", + "rollout": { + "steps": [ + { + "percent": 0 + } + ] + } + } + } + } + """.trimIndent() + val feature = generatedFeatureNewInstance() + + val privacyPlugin = (feature as PrivacyFeaturePlugin) + + assertTrue(privacyPlugin.store("testFeature", jsonFeature)) + assertTrue(testFeature.self().isEnabled()) + assertFalse(testFeature.fooFeature().isEnabled()) + } + + @Test + fun `the parent feature disabled doesn't interfer with the sub-feature state`() { + val jsonFeature = """ + { + "state": "disabled", + "features": { + "fooFeature": { + "state": "enabled", + "rollout": { + "steps": [ + { + "percent": 100 + } + ] + } + } + } + } + """.trimIndent() + val feature = generatedFeatureNewInstance() + + val privacyPlugin = (feature as PrivacyFeaturePlugin) + + assertTrue(privacyPlugin.store("testFeature", jsonFeature)) + assertFalse(testFeature.self().isEnabled()) + assertTrue(testFeature.fooFeature().isEnabled()) + } + + @Test + fun `the features have the right state for internal builds`() { + whenever(appBuildConfig.flavor).thenReturn(INTERNAL) + + val jsonFeature = """ + { + "state": "internal", + "features": { + "fooFeature": { + "state": "internal", + "rollout": { + "steps": [ + { + "percent": 0 + } + ] + } + } + } + } + """.trimIndent() + val feature = generatedFeatureNewInstance() + + val privacyPlugin = (feature as PrivacyFeaturePlugin) + + assertTrue(privacyPlugin.store("testFeature", jsonFeature)) + assertTrue(testFeature.self().isEnabled()) + // even though the state = internal, it has rollout steps, even if it is '0' that is ignored, which results in disable state + assertFalse(testFeature.fooFeature().isEnabled()) + } + + @Test + fun `the feature incremental steps are ignored when feature disabled`() { + val jsonFeature = """ + { + "state": "enabled", + "features": { + "fooFeature": { + "state": "disabled", + "rollout": { + "steps": [ + { + "percent": 1 + }, + { + "percent": 2 + }, + { + "percent": 100 + } + ] + } + } + } + } + """.trimIndent() + val feature = generatedFeatureNewInstance() + + val privacyPlugin = (feature as PrivacyFeaturePlugin) + + assertTrue(privacyPlugin.store("testFeature", jsonFeature)) + assertTrue(testFeature.self().isEnabled()) + assertFalse(testFeature.fooFeature().isEnabled()) + assertNull(testFeature.fooFeature().rolloutStep()) + } + + @Test + fun `the feature incremental steps are executed when feature is enabled`() { + val jsonFeature = """ + { + "state": "enabled", + "features": { + "fooFeature": { + "state": "enabled", + "rollout": { + "steps": [ + { + "percent": 0.5 + }, + { + "percent": 1.5 + }, + { + "percent": 2 + }, + { + "percent": 100 + } + ] + } + } + } + } + """.trimIndent() + val feature = generatedFeatureNewInstance() + + val privacyPlugin = (feature as PrivacyFeaturePlugin) + + assertTrue(privacyPlugin.store("testFeature", jsonFeature)) + assertTrue(testFeature.self().isEnabled()) + assertTrue(testFeature.fooFeature().isEnabled()) + assertEquals(4, testFeature.fooFeature().rolloutStep()) + } + + @Test + fun `the invalid rollout steps are ignored and not executed`() { + val jsonFeature = """ + { + "state": "enabled", + "features": { + "fooFeature": { + "state": "enabled", + "rollout": { + "steps": [ + { + "percent": -1 + }, + { + "percent": 100 + }, + { + "percent": 200 + } + ] + } + } + } + } + """.trimIndent() + val feature = generatedFeatureNewInstance() + + val privacyPlugin = (feature as PrivacyFeaturePlugin) + assertTrue(privacyPlugin.store("testFeature", jsonFeature)) + + assertTrue(testFeature.self().isEnabled()) + assertTrue(testFeature.fooFeature().isEnabled()) + assertEquals(1, testFeature.fooFeature().rolloutStep()) + } + + @Test + fun `disable a previously enabled incremental rollout`() { + val jsonFeature = """ + { + "state": "enabled", + "features": { + "fooFeature": { + "state": "enabled", + "rollout": { + "steps": [ + { + "percent": 100 + } + ] + } + } + } + } + """.trimIndent() + val jsonDisabled = """ + { + "state": "enabled", + "features": { + "fooFeature": { + "state": "disabled", + "rollout": { + "steps": [ + { + "percent": 100 + } + ] + } + } + } + } + """.trimIndent() + val feature = generatedFeatureNewInstance() + + val privacyPlugin = (feature as PrivacyFeaturePlugin) + assertTrue(privacyPlugin.store("testFeature", jsonFeature)) + + assertTrue(testFeature.self().isEnabled()) + assertTrue(testFeature.fooFeature().isEnabled()) + assertEquals(1, testFeature.fooFeature().rolloutStep()) + + assertTrue(privacyPlugin.store("testFeature", jsonDisabled)) + assertTrue(testFeature.self().isEnabled()) + assertFalse(testFeature.fooFeature().isEnabled()) + assertEquals(1, testFeature.fooFeature().rolloutStep()) + } + + @Test + fun `re-enable a previously disabled incremental rollout`() { + versionProvider.version = 1 + val feature = generatedFeatureNewInstance() + + val privacyPlugin = (feature as PrivacyFeaturePlugin) + // incremental rollout + assertTrue( + privacyPlugin.store( + "testFeature", + """ + { + "state": "enabled", + "features": { + "fooFeature": { + "state": "enabled", + "rollout": { + "steps": [ + { + "percent": 100 + } + ] + } + } + } + } + """.trimIndent(), + ), + ) + + // disable the previously enabled incremental rollout + assertTrue( + privacyPlugin.store( + "testFeature", + """ + { + "state": "enabled", + "features": { + "fooFeature": { + "state": "disabled", + "rollout": { + "steps": [ + { + "percent": 100 + } + ] + } + } + } + } + """.trimIndent(), + ), + ) + + assertTrue(testFeature.self().isEnabled()) + assertFalse(testFeature.fooFeature().isEnabled()) + assertEquals(1, testFeature.fooFeature().rolloutStep()) + + // re-enable the incremental rollout + assertTrue( + privacyPlugin.store( + "testFeature", + """ + { + "state": "enabled", + "features": { + "fooFeature": { + "state": "enabled", + "rollout": { + "steps": [ + { + "percent": 100 + } + ] + } + } + } + } + """.trimIndent(), + ), + ) + assertTrue(testFeature.self().isEnabled()) + assertTrue(testFeature.fooFeature().isEnabled()) + assertEquals(1, testFeature.fooFeature().rolloutStep()) + } + + @Test + fun `full feature lifecycle`() { + versionProvider.version = 1 + val feature = generatedFeatureNewInstance() + + val privacyPlugin = (feature as PrivacyFeaturePlugin) + + // all disabled + assertTrue( + privacyPlugin.store( + "testFeature", + """ + { + "state": "disabled", + "features": { + "fooFeature": { + "state": "disabled" + } + } + } + """.trimIndent(), + ), + ) + + assertFalse(testFeature.self().isEnabled()) + assertFalse(testFeature.fooFeature().isEnabled()) + assertNull(testFeature.fooFeature().rolloutStep()) + + // enable parent feature + assertTrue( + privacyPlugin.store( + "testFeature", + """ + { + "state": "enabled", + "features": { + "fooFeature": { + "state": "disabled" + } + } + } + """.trimIndent(), + ), + ) + + // add rollout information to sub-feature, still disabled + assertTrue( + privacyPlugin.store( + "testFeature", + """ + { + "state": "enabled", + "features": { + "fooFeature": { + "state": "disabled", + "rollout": { + "steps": [ + { + "percent": 10 + } + ] + } + } + } + } + """.trimIndent(), + ), + ) + + assertTrue(testFeature.self().isEnabled()) + assertFalse(testFeature.fooFeature().isEnabled()) + assertNull(testFeature.fooFeature().rolloutStep()) + + // add more rollout information to sub-feature, still disabled + assertTrue( + privacyPlugin.store( + "testFeature", + """ + { + "state": "enabled", + "features": { + "fooFeature": { + "state": "disabled", + "rollout": { + "steps": [ + { + "percent": 10 + }, + { + "percent": 20 + }, + { + "percent": 30 + } + ] + } + } + } + } + """.trimIndent(), + ), + ) + + assertTrue(testFeature.self().isEnabled()) + assertFalse(testFeature.fooFeature().isEnabled()) + assertNull(testFeature.fooFeature().rolloutStep()) + + // enable rollout + assertTrue( + privacyPlugin.store( + "testFeature", + """ + { + "state": "enabled", + "features": { + "fooFeature": { + "state": "enabled", + "rollout": { + "steps": [ + { + "percent": 10 + }, + { + "percent": 20 + }, + { + "percent": 30 + } + ] + } + } + } + } + """.trimIndent(), + ), + ) + + assertTrue(testFeature.self().isEnabled()) + // cache rollout + val rolloutStep = testFeature.fooFeature().rolloutStep() + val wasEnabled = testFeature.fooFeature().isEnabled() + + // halt rollout + assertTrue( + privacyPlugin.store( + "testFeature", + """ + { + "state": "enabled", + "features": { + "fooFeature": { + "state": "disabled", + "rollout": { + "steps": [ + { + "percent": 10 + }, + { + "percent": 20 + }, + { + "percent": 30 + } + ] + } + } + } + } + """.trimIndent(), + ), + ) + + assertTrue(testFeature.self().isEnabled()) + assertFalse(testFeature.fooFeature().isEnabled()) + assertEquals(rolloutStep, testFeature.fooFeature().rolloutStep()) + + // resume rollout just of certain app versions + assertTrue( + privacyPlugin.store( + "testFeature", + """ + { + "state": "enabled", + "features": { + "fooFeature": { + "state": "enabled", + "minSupportedVersion": 2, + "rollout": { + "steps": [ + { + "percent": 10 + }, + { + "percent": 20 + }, + { + "percent": 30 + } + ] + } + } + } + } + """.trimIndent(), + ), + ) + + assertTrue(testFeature.self().isEnabled()) + assertFalse(testFeature.fooFeature().isEnabled()) + assertEquals(rolloutStep, testFeature.fooFeature().rolloutStep()) + + // resume rollout and update app version + versionProvider.version = 2 + assertTrue( + privacyPlugin.store( + "testFeature", + """ + { + "state": "enabled", + "features": { + "fooFeature": { + "state": "enabled", + "minSupportedVersion": 2, + "rollout": { + "steps": [ + { + "percent": 10.0 + }, + { + "percent": 20 + }, + { + "percent": 30 + } + ] + } + } + } + } + """.trimIndent(), + ), + ) + + assertTrue(testFeature.self().isEnabled()) + assertEquals(wasEnabled, testFeature.fooFeature().isEnabled()) + assertEquals(rolloutStep, testFeature.fooFeature().rolloutStep()) + + // finish rollout + assertTrue( + privacyPlugin.store( + "testFeature", + """ + { + "state": "enabled", + "features": { + "fooFeature": { + "state": "enabled", + "minSupportedVersion": 2, + "rollout": { + "steps": [ + { + "percent": 10 + }, + { + "percent": 20 + }, + { + "percent": 30 + }, + { + "percent": 100 + } + ] + } + } + } + } + """.trimIndent(), + ), + ) + + assertTrue(testFeature.self().isEnabled()) + assertTrue(testFeature.fooFeature().isEnabled()) + if (wasEnabled) { + assertEquals(rolloutStep, testFeature.fooFeature().rolloutStep()) + } else { + assertNotEquals(rolloutStep, testFeature.fooFeature().rolloutStep()) + assertEquals(4, testFeature.fooFeature().rolloutStep()) + } + + // remove steps + assertTrue( + privacyPlugin.store( + "testFeature", + """ + { + "state": "enabled", + "features": { + "fooFeature": { + "state": "enabled", + "minSupportedVersion": 2 + } + } + } + """.trimIndent(), + ), + ) + + assertTrue(testFeature.self().isEnabled()) + assertTrue(testFeature.fooFeature().isEnabled()) + if (wasEnabled) { + assertEquals(rolloutStep, testFeature.fooFeature().rolloutStep()) + } else { + assertNotEquals(rolloutStep, testFeature.fooFeature().rolloutStep()) + assertEquals(4, testFeature.fooFeature().rolloutStep()) + } + } + + private fun generatedFeatureNewInstance(): Any { + return Class + .forName("com.duckduckgo.feature.toggles.codegen.TestTriggerFeature_RemoteFeature") + .getConstructor( + FeatureExceptions.Store::class.java, + FeatureSettings.Store::class.java, + dagger.Lazy::class.java as Class<*>, + AppBuildConfig::class.java, + Context::class.java, + ).newInstance( + FeatureExceptions.EMPTY_STORE, + FeatureSettings.EMPTY_STORE, + Lazy { testFeature }, + appBuildConfig, + context, + ) + } + + private fun Toggle.rolloutStep(): Int? { + return getRawStoredState()?.rolloutStep + } +} + +private class FakeAppVersionProvider { + var version = Int.MAX_VALUE +} 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 new file mode 100644 index 000000000000..0b4d0a3d2151 --- /dev/null +++ b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/codegen/TestTriggerFeature.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.feature.toggles.codegen + +import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.DefaultValue + +abstract class TriggerTestScope private constructor() + +@ContributesRemoteFeature( + scope = TriggerTestScope::class, + featureName = "testFeature", +) +interface TestTriggerFeature { + @DefaultValue(false) + fun self(): Toggle + + @DefaultValue(false) + fun fooFeature(): Toggle +}