From fab9cea10c74ebc99dc276ba53a426744a1051ea Mon Sep 17 00:00:00 2001 From: Aitor Viana Date: Mon, 6 Oct 2025 11:42:59 +0100 Subject: [PATCH] Implement caching Toggle.Store and Toggle@enabled() flow API --- build.gradle | 2 +- .../feature-toggles-api/build.gradle | 4 + .../feature/toggles/api/FeatureToggles.kt | 62 +++++- .../toggles/api/internal/CachedToggleStore.kt | 110 +++++++++++ .../feature-toggles-impl/build.gradle | 17 ++ .../feature/toggles/api/FeatureTogglesTest.kt | 41 +++- .../internal/CachedToggleStoreListenerTest.kt | 144 ++++++++++++++ .../internal/CachedToggleStorePerfTest.kt | 179 ++++++++++++++++++ .../toggles/internal/CachedToggleStoreTest.kt | 69 +++++++ 9 files changed, 624 insertions(+), 4 deletions(-) create mode 100644 feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/internal/CachedToggleStore.kt create mode 100644 feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/internal/CachedToggleStoreListenerTest.kt create mode 100644 feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/internal/CachedToggleStorePerfTest.kt create mode 100644 feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/internal/CachedToggleStoreTest.kt diff --git a/build.gradle b/build.gradle index 77ae343b022f..c789f2b2d75b 100644 --- a/build.gradle +++ b/build.gradle @@ -53,7 +53,7 @@ subprojects { apply plugin: 'org.jetbrains.dokka' } - String[] allowAndroidTestsIn = ["app", "sync-lib", "httpsupgrade-impl"] + String[] allowAndroidTestsIn = ["app", "sync-lib", "httpsupgrade-impl", "feature-toggles-impl"] if (!allowAndroidTestsIn.contains(project.name)) { project.projectDir.eachFile(groovy.io.FileType.DIRECTORIES) { File parent -> if (parent.name == "src") { diff --git a/feature-toggles/feature-toggles-api/build.gradle b/feature-toggles/feature-toggles-api/build.gradle index d05b632138cc..c0c460eb841b 100644 --- a/feature-toggles/feature-toggles-api/build.gradle +++ b/feature-toggles/feature-toggles-api/build.gradle @@ -36,5 +36,9 @@ dependencies { implementation Google.dagger implementation "org.apache.commons:commons-math3:_" + implementation("com.google.guava:guava:_") { + exclude group: 'com.google.guava', module: 'listenablefuture' + } + implementation KotlinX.coroutines.core } 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 e32b37c6d4a3..a2f3bf1817c6 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 @@ -20,7 +20,12 @@ import com.duckduckgo.feature.toggles.api.Toggle.FeatureName import com.duckduckgo.feature.toggles.api.Toggle.State import com.duckduckgo.feature.toggles.api.Toggle.State.Cohort import com.duckduckgo.feature.toggles.api.Toggle.State.CohortName +import com.duckduckgo.feature.toggles.api.internal.CachedToggleStore +import com.duckduckgo.feature.toggles.api.internal.CachedToggleStore.Listener import com.duckduckgo.feature.toggles.internal.api.FeatureTogglesCallback +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow import org.apache.commons.math3.distribution.EnumeratedIntegerDistribution import java.lang.reflect.Method import java.lang.reflect.Proxy @@ -119,7 +124,7 @@ class FeatureToggles private constructor( }.getOrNull() != null return ToggleImpl( - store = store, + store = if (store is CachedToggleStore) store else CachedToggleStore(store), key = getToggleNameForMethod(method), defaultValue = resolvedDefaultValue, isInternalAlwaysEnabled = isInternalAlwaysEnabledAnnotated, @@ -172,6 +177,39 @@ interface Toggle { */ suspend fun enroll(): Boolean + /** + * Returns a cold [Flow] of [Boolean] values representing whether this toggle is enabled. + * + * ### Behavior + * - When a collector starts, the current toggle value is emitted immediately. + * - Subsequent emissions occur whenever the underlying [store] writes a new [State]. + * - The flow is cold: a listener is only registered while it is being collected. + * - When collection is cancelled or completed, the registered listener is automatically. + * + * ### Thread-safety + * Emissions are delivered on the coroutine context where the flow is collected. + * Multiple collectors will each register their own listener instance. + * + * ### Example + * ``` + * viewModelScope.launch { + * toggle.enabled() + * .distinctUntilChanged() + * .collect { enabled -> + * if (enabled) { + * showOnboarding() + * } else { + * showLoading() + * } + * } + * } + * ``` + * + * @return a cold [Flow] that emits the current enabled state and any subsequent changes + * until the collector is cancelled. + */ + fun enabled(): Flow + /** * This method * - Returns whether the feature flag state is enabled or disabled. @@ -386,6 +424,28 @@ internal class ToggleImpl constructor( return enrollInternal() } + override fun enabled(): Flow = callbackFlow { + // emit current value when someone starts collecting + trySend(isEnabled()) + + val unsubscribe = when (val s = store) { + is CachedToggleStore -> { + s.setListener( + object : Listener { + override fun onToggleStored(newValue: State) { + // emit value just stored + trySend(isEnabled()) + } + }, + ) + } + else -> { -> Unit } + } + + // when flow collection is cancelled/closed, run the unsubscribe to avoid leaking the listener + awaitClose { unsubscribe() } + } + private fun enrollInternal(force: Boolean = false): Boolean { // if the Toggle is not enabled, then we don't enroll if (isEnabled() == false) { diff --git a/feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/internal/CachedToggleStore.kt b/feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/internal/CachedToggleStore.kt new file mode 100644 index 000000000000..f4f7241cb417 --- /dev/null +++ b/feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/internal/CachedToggleStore.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 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.api.internal + +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.State +import com.google.common.cache.CacheBuilder +import com.google.common.cache.CacheLoader +import com.google.common.cache.LoadingCache +import org.jetbrains.annotations.TestOnly +import java.util.Optional +import kotlin.jvm.optionals.getOrNull + +class CachedToggleStore constructor( + private val store: Toggle.Store, +) : Toggle.Store { + @Volatile + private var listener: Listener? = null + + private val cache: LoadingCache> = + CacheBuilder + .newBuilder() + .maximumSize(50) + .build( + object : CacheLoader>() { + override fun load(key: String): Optional = Optional.ofNullable(store.get(key)) + }, + ) + + override fun set( + key: String, + state: State, + ) { + cache.asMap().compute(key) { k, _ -> + store.set(k, state) + Optional.of(state) + } + // Notify AFTER compute() to avoid deadlocks or re-entrancy into the cache/store. + // If the store.set() above throws, this never runs (which is what we want). + // Swallow listener exceptions so they don't break writes. + listener?.runCatching { onToggleStored(state) } + } + + /** + * Registers a [Listener] to observe changes in toggle states stored by this [CachedToggleStore]. + * + * Only a single listener is supported at a time. When a new listener is set, it replaces any + * previously registered listener. To avoid memory leaks, callers should always invoke the returned + * unsubscribe function when the listener is no longer needed (for example, when the collector + * of a [kotlinx.coroutines.flow.callbackFlow] is closed). + * + * Example usage: + * ``` + * val unsubscribe = cachedToggleStore.setListener(object : CachedToggleStore.Listener { + * override fun onToggleStored(newValue: Toggle.State, oldValue: Toggle.State?) { + * // React to state change + * } + * }) + * + * // Later, when no longer interested: + * unsubscribe() + * ``` + * + * @param listener the [Listener] instance that will receive callbacks for each `set` operation. + * @return a function that removes the listener when invoked. The returned function is safe to call + * multiple times and will only clear the listener if it is the same instance that was + * originally registered. + */ + fun setListener(listener: Listener?): () -> Unit { + this.listener = listener + + return { if (this.listener === listener) this.listener = null } + } + + /** + * DO NOT USE outside tests + */ + @TestOnly + fun invalidateAll() { + cache.invalidateAll() + } + + override fun get(key: String): State? { + val value = cache.get(key).getOrNull() + if (value == null) { + // avoid negative caching + cache.invalidate(key) + } + + return value + } + + interface Listener { + fun onToggleStored(newValue: State) + } +} diff --git a/feature-toggles/feature-toggles-impl/build.gradle b/feature-toggles/feature-toggles-impl/build.gradle index 8418b1ac6dca..cbd701398ea4 100644 --- a/feature-toggles/feature-toggles-impl/build.gradle +++ b/feature-toggles/feature-toggles-impl/build.gradle @@ -52,10 +52,24 @@ dependencies { testImplementation project(':data-store-test') testImplementation "org.mockito.kotlin:mockito-kotlin:_" testImplementation "com.squareup.moshi:moshi-kotlin:_" + testImplementation CashApp.turbine testImplementation Testing.robolectric testImplementation AndroidX.test.ext.junit testImplementation Square.retrofit2.converter.moshi testImplementation Testing.junit4 + androidTestImplementation AndroidX.test.runner + androidTestImplementation AndroidX.test.ext.junit + androidTestImplementation Square.retrofit2.converter.moshi + + coreLibraryDesugaring Android.tools.desugarJdkLibs +} + +configurations.all { + exclude(group: "com.google.guava", module: "listenablefuture") +} + +tasks.register('androidTestsBuild') { + dependsOn 'assembleDebugAndroidTest' } anvil { @@ -68,4 +82,7 @@ android { lintOptions { baseline file("lint-baseline.xml") } + compileOptions { + coreLibraryDesugaringEnabled = true + } } 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 5150887f6646..687955c2d48d 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 @@ -17,6 +17,7 @@ package com.duckduckgo.feature.toggles.api import android.annotation.SuppressLint +import app.cash.turbine.test import com.duckduckgo.appbuildconfig.api.BuildFlavor import com.duckduckgo.feature.toggles.api.Cohorts.CONTROL import com.duckduckgo.feature.toggles.api.Cohorts.TREATMENT @@ -24,7 +25,9 @@ import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue import com.duckduckgo.feature.toggles.api.Toggle.FeatureName import com.duckduckgo.feature.toggles.api.Toggle.State import com.duckduckgo.feature.toggles.api.Toggle.State.CohortName +import com.duckduckgo.feature.toggles.api.internal.CachedToggleStore import com.duckduckgo.feature.toggles.internal.api.FeatureTogglesCallback +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -41,13 +44,13 @@ class FeatureTogglesTest { private lateinit var feature: TestFeature private lateinit var provider: FakeProvider - private lateinit var toggleStore: FakeToggleStore + private lateinit var toggleStore: Toggle.Store private lateinit var callback: FakeFeatureTogglesCallback @Before fun setup() { provider = FakeProvider() - toggleStore = FakeToggleStore() + toggleStore = CachedToggleStore(FakeToggleStore()) callback = FakeFeatureTogglesCallback() feature = FeatureToggles.Builder() .store(toggleStore) @@ -121,6 +124,40 @@ class FeatureTogglesTest { feature.noDefaultValue().isEnabled() } + @Test + fun whenEnabledByDefaultThenEmitEnabled() = runTest { + feature.enabledByDefault().enabled().test { + assertTrue(awaitItem()) + expectNoEvents() + } + } + + @Test + fun whenEnabledByDefaultAndSetEnabledThenEmitTwoEnables() = runTest { + feature.enabledByDefault().enabled().test { + assertTrue(awaitItem()) + feature.enabledByDefault().setRawStoredState(Toggle.State(enable = false)) + assertFalse(awaitItem()) + expectNoEvents() + } + } + + @Test + fun enableValuesSetBeforeRegistrationGetLost() = runTest { + feature.enabledByDefault().setRawStoredState(Toggle.State(enable = false)) + feature.enabledByDefault().enabled().test { + assertFalse(awaitItem()) + expectNoEvents() + } + } + + @Test + fun whenDroppingEmissionThenNoValueEmitted() = runTest { + feature.enabledByDefault().enabled().drop(1).test { + expectNoEvents() + } + } + @Test(expected = IllegalArgumentException::class) fun whenWrongReturnValueThenThrow() { feature.wrongReturnValue() diff --git a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/internal/CachedToggleStoreListenerTest.kt b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/internal/CachedToggleStoreListenerTest.kt new file mode 100644 index 000000000000..1a3ceb555b91 --- /dev/null +++ b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/internal/CachedToggleStoreListenerTest.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 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.internal + +import com.duckduckgo.feature.toggles.api.FakeToggleStore +import com.duckduckgo.feature.toggles.api.Toggle.State +import com.duckduckgo.feature.toggles.api.internal.CachedToggleStore +import org.junit.Assert.* +import org.junit.Test + +class CachedToggleStoreListenerTest { + + private val store = FakeToggleStore() + private val cached = CachedToggleStore(store) + + private class RecordingListener : CachedToggleStore.Listener { + val calls = mutableListOf() + override fun onToggleStored(newValue: State) { + calls += newValue + } + } + + @Test + fun `setting a listener notifies it when set is called`() { + val listener = RecordingListener() + cached.setListener(listener) + + val s1 = State(remoteEnableState = true, settings = "a") + cached.set("k", s1) + + assertEquals(listOf(s1), listener.calls) + } + + @Test + fun `unsubscribing a listener stops further notifications`() { + val listener = RecordingListener() + val unsubscribe = cached.setListener(listener) + + val s1 = State(remoteEnableState = true, settings = "a") + cached.set("k", s1) + assertEquals(1, listener.calls.size) + + // unsubscribe and set again + unsubscribe() + val s2 = State(remoteEnableState = true, settings = "b") + cached.set("k", s2) + + // still only the first call + assertEquals(1, listener.calls.size) + assertEquals(s1, listener.calls[0]) + } + + @Test + fun `replacing the listener only notifies the latest one and old unsubscribe does not remove the new listener`() { + val l1 = RecordingListener() + val unsub1 = cached.setListener(l1) + + val s1 = State(remoteEnableState = true, settings = "a") + cached.set("k", s1) + assertEquals(listOf(s1), l1.calls) + + val l2 = RecordingListener() + val unsub2 = cached.setListener(l2) + + val s2 = State(remoteEnableState = true, settings = "b") + cached.set("k", s2) + + // l1 should not receive the second notification + assertEquals(listOf(s1), l1.calls) + // l2 should receive it + assertEquals(listOf(s2), l2.calls) + + // Calling unsub1 (from the previous listener) must NOT clear l2 + unsub1() + val s3 = State(remoteEnableState = true, settings = "c") + cached.set("k", s3) + // l2 still receives notifications + assertEquals(listOf(s2, s3), l2.calls) + + // Now unsubscribe l2 and verify no more notifications + unsub2() + val s4 = State(remoteEnableState = true, settings = "d") + cached.set("k", s4) + assertEquals(listOf(s2, s3), l2.calls) + } + + @Test + fun `listener exceptions are swallowed and do not break writes`() { + val throwing = object : CachedToggleStore.Listener { + override fun onToggleStored(newValue: State) { + throw RuntimeException("boom") + } + } + cached.setListener(throwing) + + val s = State(remoteEnableState = true, settings = "x") + + // Should not throw + cached.set("k", s) + + // And the value must still be written to the backing store and visible via cache + assertEquals(s, store.get("k")) + assertEquals(s, cached.get("k")) + } + + @Test + fun `setting listener to null clears notifications`() { + val l = RecordingListener() + cached.setListener(l) + + val s1 = State(remoteEnableState = true, settings = "a") + cached.set("k", s1) + assertEquals(1, l.calls.size) + + // Clear listener + val unsub = cached.setListener(null) + + val s2 = State(remoteEnableState = true, settings = "b") + cached.set("k", s2) + + // No new calls after clearing + assertEquals(1, l.calls.size) + + // Calling the returned unsubscribe is a no-op but should be safe + unsub() + val s3 = State(remoteEnableState = true, settings = "c") + cached.set("k", s3) + assertEquals(1, l.calls.size) + } +} diff --git a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/internal/CachedToggleStorePerfTest.kt b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/internal/CachedToggleStorePerfTest.kt new file mode 100644 index 000000000000..c1236c34c5fa --- /dev/null +++ b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/internal/CachedToggleStorePerfTest.kt @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2025 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.internal + +import android.content.Context +import android.content.SharedPreferences +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.State +import com.duckduckgo.feature.toggles.api.internal.CachedToggleStore +import com.squareup.moshi.Moshi +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.system.measureNanoTime + +@RunWith(AndroidJUnit4::class) +class CachedToggleStorePerfTest { + + private lateinit var store: SharedPreferencesToggleStore + private lateinit var cachedStore: CachedToggleStore + + @Before + fun setUp() { + val ctx = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext + store = SharedPreferencesToggleStore(ctx) + cachedStore = CachedToggleStore(store) + // hard reset SP + cache so tests are independent + store.clearForTests() + cachedStore.invalidateAllForTests() + forceGc() + } + + @Test + fun `cache hits are significantly faster than reading directly from the store`() { + val state = State(remoteEnableState = true, settings = "enabled") + val key = "feature_toggle_hit" + val iterations = 50_000 + + cachedStore.set(key, state) + + // warmup both paths + repeat(10_000) { cachedStore.get(key) } + repeat(10_000) { store.get(key) } + + val tCache = medianNanos { + repeat(iterations) { cachedStore.get(key) } + } + val tStore = medianNanos { + repeat(iterations) { store.get(key) } + } + + assertFasterWithMargin(fast = tCache, slow = tStore, atLeastRatio = 1.5) + } + + @Test + fun `first load misses are not faster when using the cache`() { + val state = State(remoteEnableState = true, settings = "enabled") + val keys = List(500) { "k$it" } // small working set prevents OOM + + // preload store (single commit inside helper) + store.bulkPut(keys, state) + + // warmup on unrelated keys to trigger JIT + repeat(2_000) { store.get("x$it") } + repeat(2_000) { cachedStore.get("y$it") } + + // ensure cache starts empty + cachedStore.invalidateAllForTests() + + val tStore = medianNanos { keys.forEach { store.get(it) } } + cachedStore.invalidateAllForTests() + val tCache = medianNanos { keys.forEach { cachedStore.get(it) } } // compulsory loads + + // cache path should be same or slower; allow small slack + val ratio = tCache.toDouble() / tStore.toDouble() + assertTrue( + "Compulsory cache loads should be ~same or slower. ratio=$ratio (cache=$tCache, store=$tStore)", + ratio >= 0.90, + ) + } + + @Test + fun `writing once and reading many times is faster when using the cached store`() { + val state = State(remoteEnableState = true, settings = "enabled") + val key = "feature_toggle_wr" + val readsPerWrite = 200_000 + + // warmup both paths + cachedStore.set(key, state) + repeat(10_000) { cachedStore.get(key) } + repeat(10_000) { store.get(key) } + + val tStore = medianNanos { + store.set(key, state) + repeat(readsPerWrite) { store.get(key) } + } + val tCache = medianNanos { + cachedStore.set(key, state) + repeat(readsPerWrite) { cachedStore.get(key) } + } + + assertTrue("cache=$tCache, store=$tStore", tCache < tStore) + } + + // ---- helpers ---- + + private fun forceGc() { + System.gc() + Thread.sleep(50) + System.runFinalization() + Thread.sleep(50) + } + + private inline fun medianNanos(runs: Int = 7, crossinline block: () -> Unit): Long { + val samples = LongArray(runs) + repeat(runs) { i -> + val t = measureNanoTime { block() } + samples[i] = t + forceGc() + } + return samples.sorted()[runs / 2] + } + + private fun assertFasterWithMargin(fast: Long, slow: Long, atLeastRatio: Double = 1.15) { + val ratio = slow.toDouble() / fast.toDouble() + assertTrue( + "Expected ≥ ${"%.2f".format(atLeastRatio)}x speedup; got ${"%.2f".format(ratio)}x (fast=$fast ns, slow=$slow ns)", + ratio >= atLeastRatio, + ) + } +} + +// --- SP-backed store with test helpers --- + +private class SharedPreferencesToggleStore(context: Context) : Toggle.Store { + private val adapter = Moshi.Builder().build().adapter(State::class.java) + private val sp: SharedPreferences = + context.getSharedPreferences("toggle_store", Context.MODE_PRIVATE) + + override fun set(key: String, state: State) { + sp.edit().putString(key, adapter.toJson(state)).commit() // commit in tests + } + + override fun get(key: String): State? = + sp.getString(key, null)?.let { adapter.fromJson(it) } + + fun clearForTests() { + sp.edit().clear().commit() + } + + fun bulkPut(keys: List, state: State) { + val json = adapter.toJson(state) + sp.edit().apply { + keys.forEach { putString(it, json) } + }.commit() + } +} + +// expose cache clears for tests (or re-create the cached store in @Before) +private fun CachedToggleStore.invalidateAllForTests() { + invalidateAll() +} diff --git a/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/internal/CachedToggleStoreTest.kt b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/internal/CachedToggleStoreTest.kt new file mode 100644 index 000000000000..4a63d38abe94 --- /dev/null +++ b/feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/internal/CachedToggleStoreTest.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2025 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.internal + +import com.duckduckgo.feature.toggles.api.FakeToggleStore +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.internal.CachedToggleStore +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CachedToggleStoreTest { + private val store = FakeToggleStore() + private val cachedToggleStore = CachedToggleStore(store) + + @Test + fun `calling set sets the value in the backing store`() { + val expected = Toggle.State(remoteEnableState = true, settings = "") + cachedToggleStore.set("test", expected) + assertEquals(expected, store.get("test")) + assertEquals(expected, cachedToggleStore.get("test")) + } + + @Test + fun `calling get gets the value from the backing store`() { + val expected = Toggle.State(remoteEnableState = true, settings = "") + store.set("test", expected) + assertEquals(expected, cachedToggleStore.get("test")) + } + + @Test + fun `calling get gets null value when backing store doesn't have such value`() { + assertNull(cachedToggleStore.get("test")) + } + + @Test + fun `negative caching occurs when first access is a miss, then backing store is updated directly`() { + val expected = Toggle.State(remoteEnableState = true, settings = "") + // this call to cachedToggleStore.get() might trigger negative caching. + assertEquals(null, cachedToggleStore.get("test")) + store.set("test", expected) + val actual = cachedToggleStore.get("test") + assertEquals(expected, actual) + } + + @Test + fun `no negative caching when write goes through wrapper`() { + val expected = Toggle.State(remoteEnableState = true, settings = "") + // this call to cachedToggleStore.get() might trigger negative caching. + assertEquals(null, cachedToggleStore.get("test")) + cachedToggleStore.set("test", expected) + val actual = cachedToggleStore.get("test") + assertEquals(expected, actual) + } +}