Skip to content

Commit 2a39c6e

Browse files
committed
Implement caching Toggle.Store and Toggle@enabled() flow API
1 parent fb4b680 commit 2a39c6e

File tree

9 files changed

+624
-4
lines changed

9 files changed

+624
-4
lines changed

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ subprojects {
5353
apply plugin: 'org.jetbrains.dokka'
5454
}
5555

56-
String[] allowAndroidTestsIn = ["app", "sync-lib", "httpsupgrade-impl"]
56+
String[] allowAndroidTestsIn = ["app", "sync-lib", "httpsupgrade-impl", "feature-toggles-impl"]
5757
if (!allowAndroidTestsIn.contains(project.name)) {
5858
project.projectDir.eachFile(groovy.io.FileType.DIRECTORIES) { File parent ->
5959
if (parent.name == "src") {

feature-toggles/feature-toggles-api/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,5 +36,9 @@ dependencies {
3636

3737
implementation Google.dagger
3838
implementation "org.apache.commons:commons-math3:_"
39+
implementation("com.google.guava:guava:_") {
40+
exclude group: 'com.google.guava', module: 'listenablefuture'
41+
}
42+
implementation KotlinX.coroutines.core
3943
}
4044

feature-toggles/feature-toggles-api/src/main/java/com/duckduckgo/feature/toggles/api/FeatureToggles.kt

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ import com.duckduckgo.feature.toggles.api.Toggle.FeatureName
2020
import com.duckduckgo.feature.toggles.api.Toggle.State
2121
import com.duckduckgo.feature.toggles.api.Toggle.State.Cohort
2222
import com.duckduckgo.feature.toggles.api.Toggle.State.CohortName
23+
import com.duckduckgo.feature.toggles.api.internal.CachedToggleStore
24+
import com.duckduckgo.feature.toggles.api.internal.CachedToggleStore.Listener
2325
import com.duckduckgo.feature.toggles.internal.api.FeatureTogglesCallback
26+
import kotlinx.coroutines.channels.awaitClose
27+
import kotlinx.coroutines.flow.Flow
28+
import kotlinx.coroutines.flow.callbackFlow
2429
import org.apache.commons.math3.distribution.EnumeratedIntegerDistribution
2530
import java.lang.reflect.Method
2631
import java.lang.reflect.Proxy
@@ -119,7 +124,7 @@ class FeatureToggles private constructor(
119124
}.getOrNull() != null
120125

121126
return ToggleImpl(
122-
store = store,
127+
store = if (store is CachedToggleStore) store else CachedToggleStore(store),
123128
key = getToggleNameForMethod(method),
124129
defaultValue = resolvedDefaultValue,
125130
isInternalAlwaysEnabled = isInternalAlwaysEnabledAnnotated,
@@ -172,6 +177,39 @@ interface Toggle {
172177
*/
173178
suspend fun enroll(): Boolean
174179

180+
/**
181+
* Returns a cold [Flow] of [Boolean] values representing whether this toggle is enabled.
182+
*
183+
* ### Behavior
184+
* - When a collector starts, the current toggle value is emitted immediately.
185+
* - Subsequent emissions occur whenever the underlying [store] writes a new [State].
186+
* - The flow is cold: a listener is only registered while it is being collected.
187+
* - When collection is cancelled or completed, the registered listener is automatically unregistered.
188+
*
189+
* ### Thread-safety
190+
* Emissions are delivered on the coroutine context where the flow is collected.
191+
* Multiple collectors will each register their own listener instance.
192+
*
193+
* ### Example
194+
* ```
195+
* viewModelScope.launch {
196+
* toggle.enabled()
197+
* .distinctUntilChanged()
198+
* .collect { enabled ->
199+
* if (enabled) {
200+
* showOnboarding()
201+
* } else {
202+
* showLoading()
203+
* }
204+
* }
205+
* }
206+
* ```
207+
*
208+
* @return a cold [Flow] that emits the current enabled state and any subsequent changes
209+
* until the collector is cancelled.
210+
*/
211+
fun enabled(): Flow<Boolean>
212+
175213
/**
176214
* This method
177215
* - Returns whether the feature flag state is enabled or disabled.
@@ -386,6 +424,28 @@ internal class ToggleImpl constructor(
386424
return enrollInternal()
387425
}
388426

427+
override fun enabled(): Flow<Boolean> = callbackFlow {
428+
// emit current value when someone starts collecting
429+
trySend(isEnabled())
430+
431+
val unsubscribe = when (val s = store) {
432+
is CachedToggleStore -> {
433+
s.setListener(
434+
object : Listener {
435+
override fun onToggleStored(newValue: State) {
436+
// emit value just stored
437+
trySend(isEnabled())
438+
}
439+
},
440+
)
441+
}
442+
else -> { -> Unit }
443+
}
444+
445+
// when flow collection is cancelled/closed, run the unsubscribe to avoid leaking the listener
446+
awaitClose { unsubscribe() }
447+
}
448+
389449
private fun enrollInternal(force: Boolean = false): Boolean {
390450
// if the Toggle is not enabled, then we don't enroll
391451
if (isEnabled() == false) {
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.feature.toggles.api.internal
18+
19+
import com.duckduckgo.feature.toggles.api.Toggle
20+
import com.duckduckgo.feature.toggles.api.Toggle.State
21+
import com.google.common.cache.CacheBuilder
22+
import com.google.common.cache.CacheLoader
23+
import com.google.common.cache.LoadingCache
24+
import org.jetbrains.annotations.TestOnly
25+
import java.util.Optional
26+
import kotlin.jvm.optionals.getOrNull
27+
28+
class CachedToggleStore constructor(
29+
private val store: Toggle.Store,
30+
) : Toggle.Store {
31+
@Volatile
32+
private var listener: Listener? = null
33+
34+
private val cache: LoadingCache<String, Optional<State>> =
35+
CacheBuilder
36+
.newBuilder()
37+
.maximumSize(50)
38+
.build(
39+
object : CacheLoader<String, Optional<State>>() {
40+
override fun load(key: String): Optional<State> = Optional.ofNullable(store.get(key))
41+
},
42+
)
43+
44+
override fun set(
45+
key: String,
46+
state: State,
47+
) {
48+
cache.asMap().compute(key) { k, _ ->
49+
store.set(k, state)
50+
Optional.of(state)
51+
}
52+
// Notify AFTER compute() to avoid deadlocks or re-entrancy into the cache/store.
53+
// If the store.set() above throws, this never runs (which is what we want).
54+
// Swallow listener exceptions so they don't break writes.
55+
listener?.runCatching { onToggleStored(state) }
56+
}
57+
58+
/**
59+
* Registers a [Listener] to observe changes in toggle states stored by this [CachedToggleStore].
60+
*
61+
* Only a single listener is supported at a time. When a new listener is set, it replaces any
62+
* previously registered listener. To avoid memory leaks, callers should always invoke the returned
63+
* unsubscribe function when the listener is no longer needed (for example, when the collector
64+
* of a [kotlinx.coroutines.flow.callbackFlow] is closed).
65+
*
66+
* Example usage:
67+
* ```
68+
* val unsubscribe = cachedToggleStore.setListener(object : CachedToggleStore.Listener {
69+
* override fun onToggleStored(newValue: Toggle.State, oldValue: Toggle.State?) {
70+
* // React to state change
71+
* }
72+
* })
73+
*
74+
* // Later, when no longer interested:
75+
* unsubscribe()
76+
* ```
77+
*
78+
* @param listener the [Listener] instance that will receive callbacks for each `set` operation.
79+
* @return a function that removes the listener when invoked. The returned function is safe to call
80+
* multiple times and will only clear the listener if it is the same instance that was
81+
* originally registered.
82+
*/
83+
fun setListener(listener: Listener?): () -> Unit {
84+
this.listener = listener
85+
86+
return { if (this.listener === listener) this.listener = null }
87+
}
88+
89+
/**
90+
* DO NOT USE outside tests
91+
*/
92+
@TestOnly
93+
fun invalidateAll() {
94+
cache.invalidateAll()
95+
}
96+
97+
override fun get(key: String): State? {
98+
val value = cache.get(key).getOrNull()
99+
if (value == null) {
100+
// avoid negative caching
101+
cache.invalidate(key)
102+
}
103+
104+
return value
105+
}
106+
107+
interface Listener {
108+
fun onToggleStored(newValue: State)
109+
}
110+
}

feature-toggles/feature-toggles-impl/build.gradle

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,24 @@ dependencies {
5252
testImplementation project(':data-store-test')
5353
testImplementation "org.mockito.kotlin:mockito-kotlin:_"
5454
testImplementation "com.squareup.moshi:moshi-kotlin:_"
55+
testImplementation CashApp.turbine
5556
testImplementation Testing.robolectric
5657
testImplementation AndroidX.test.ext.junit
5758
testImplementation Square.retrofit2.converter.moshi
5859
testImplementation Testing.junit4
60+
androidTestImplementation AndroidX.test.runner
61+
androidTestImplementation AndroidX.test.ext.junit
62+
androidTestImplementation Square.retrofit2.converter.moshi
63+
64+
coreLibraryDesugaring Android.tools.desugarJdkLibs
65+
}
66+
67+
configurations.all {
68+
exclude(group: "com.google.guava", module: "listenablefuture")
69+
}
70+
71+
tasks.register('androidTestsBuild') {
72+
dependsOn 'assembleDebugAndroidTest'
5973
}
6074

6175
anvil {
@@ -68,4 +82,7 @@ android {
6882
lintOptions {
6983
baseline file("lint-baseline.xml")
7084
}
85+
compileOptions {
86+
coreLibraryDesugaringEnabled = true
87+
}
7188
}

feature-toggles/feature-toggles-impl/src/test/java/com/duckduckgo/feature/toggles/api/FeatureTogglesTest.kt

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,17 @@
1717
package com.duckduckgo.feature.toggles.api
1818

1919
import android.annotation.SuppressLint
20+
import app.cash.turbine.test
2021
import com.duckduckgo.appbuildconfig.api.BuildFlavor
2122
import com.duckduckgo.feature.toggles.api.Cohorts.CONTROL
2223
import com.duckduckgo.feature.toggles.api.Cohorts.TREATMENT
2324
import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue
2425
import com.duckduckgo.feature.toggles.api.Toggle.FeatureName
2526
import com.duckduckgo.feature.toggles.api.Toggle.State
2627
import com.duckduckgo.feature.toggles.api.Toggle.State.CohortName
28+
import com.duckduckgo.feature.toggles.api.internal.CachedToggleStore
2729
import com.duckduckgo.feature.toggles.internal.api.FeatureTogglesCallback
30+
import kotlinx.coroutines.flow.drop
2831
import kotlinx.coroutines.test.runTest
2932
import org.junit.Assert.assertEquals
3033
import org.junit.Assert.assertFalse
@@ -41,13 +44,13 @@ class FeatureTogglesTest {
4144

4245
private lateinit var feature: TestFeature
4346
private lateinit var provider: FakeProvider
44-
private lateinit var toggleStore: FakeToggleStore
47+
private lateinit var toggleStore: Toggle.Store
4548
private lateinit var callback: FakeFeatureTogglesCallback
4649

4750
@Before
4851
fun setup() {
4952
provider = FakeProvider()
50-
toggleStore = FakeToggleStore()
53+
toggleStore = CachedToggleStore(FakeToggleStore())
5154
callback = FakeFeatureTogglesCallback()
5255
feature = FeatureToggles.Builder()
5356
.store(toggleStore)
@@ -121,6 +124,40 @@ class FeatureTogglesTest {
121124
feature.noDefaultValue().isEnabled()
122125
}
123126

127+
@Test
128+
fun whenEnabledByDefaultThenEmitEnabled() = runTest {
129+
feature.enabledByDefault().enabled().test {
130+
assertTrue(awaitItem())
131+
expectNoEvents()
132+
}
133+
}
134+
135+
@Test
136+
fun whenEnabledByDefaultAndSetEnabledThenEmitTwoEnables() = runTest {
137+
feature.enabledByDefault().enabled().test {
138+
assertTrue(awaitItem())
139+
feature.enabledByDefault().setRawStoredState(Toggle.State(enable = false))
140+
assertFalse(awaitItem())
141+
expectNoEvents()
142+
}
143+
}
144+
145+
@Test
146+
fun enableValuesSetBeforeRegistrationGetLost() = runTest {
147+
feature.enabledByDefault().setRawStoredState(Toggle.State(enable = false))
148+
feature.enabledByDefault().enabled().test {
149+
assertFalse(awaitItem())
150+
expectNoEvents()
151+
}
152+
}
153+
154+
@Test
155+
fun whenDroppingEmissionThenNoValueEmitted() = runTest {
156+
feature.enabledByDefault().enabled().drop(1).test {
157+
expectNoEvents()
158+
}
159+
}
160+
124161
@Test(expected = IllegalArgumentException::class)
125162
fun whenWrongReturnValueThenThrow() {
126163
feature.wrongReturnValue()

0 commit comments

Comments
 (0)