Skip to content

Commit 949a693

Browse files
committed
emit current state
1 parent 4a54161 commit 949a693

File tree

9 files changed

+156
-77
lines changed

9 files changed

+156
-77
lines changed

features/dd-sdk-android-flags/api/apiSurface

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ interface com.datadog.android.flags.FlagsClient
88
fun resolveIntValue(String, Int): Int
99
fun resolveStructureValue(String, org.json.JSONObject): org.json.JSONObject
1010
fun <T: Any> resolve(String, T): com.datadog.android.flags.model.ResolutionDetails<T>
11-
fun getCurrentState(): com.datadog.android.flags.model.FlagsClientState
1211
fun addStateListener(FlagsStateListener)
1312
fun removeStateListener(FlagsStateListener)
1413
class Builder
@@ -26,19 +25,21 @@ data class com.datadog.android.flags.FlagsConfiguration
2625
fun build(): FlagsConfiguration
2726
companion object
2827
interface com.datadog.android.flags.FlagsStateListener
29-
fun onStateChanged(com.datadog.android.flags.model.FlagsClientState, Throwable? = null)
28+
fun onStateChanged(com.datadog.android.flags.model.FlagsClientState)
3029
enum com.datadog.android.flags.model.ErrorCode
3130
- PROVIDER_NOT_READY
3231
- FLAG_NOT_FOUND
3332
- PARSE_ERROR
3433
- TYPE_MISMATCH
3534
data class com.datadog.android.flags.model.EvaluationContext
3635
constructor(String, Map<String, String> = emptyMap())
37-
enum com.datadog.android.flags.model.FlagsClientState
38-
- NOT_READY
39-
- READY
40-
- RECONCILING
41-
- ERROR
36+
sealed class com.datadog.android.flags.model.FlagsClientState
37+
object NotReady : FlagsClientState
38+
object Ready : FlagsClientState
39+
object Reconciling : FlagsClientState
40+
object Stale : FlagsClientState
41+
data class Error : FlagsClientState
42+
constructor(Throwable? = null)
4243
data class com.datadog.android.flags.model.ResolutionDetails<T: Any>
4344
constructor(T, String? = null, ResolutionReason? = null, ErrorCode? = null, String? = null, Map<String, Any> = emptyMap())
4445
enum com.datadog.android.flags.model.ResolutionReason

features/dd-sdk-android-flags/api/dd-sdk-android-flags.api

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ public abstract interface class com/datadog/android/flags/FlagsClient {
1212
public static fun get ()Lcom/datadog/android/flags/FlagsClient;
1313
public static fun get (Ljava/lang/String;)Lcom/datadog/android/flags/FlagsClient;
1414
public static fun get (Ljava/lang/String;Lcom/datadog/android/api/SdkCore;)Lcom/datadog/android/flags/FlagsClient;
15-
public abstract fun getCurrentState ()Lcom/datadog/android/flags/model/FlagsClientState;
1615
public abstract fun removeStateListener (Lcom/datadog/android/flags/FlagsStateListener;)V
1716
public abstract fun resolve (Ljava/lang/String;Ljava/lang/Object;)Lcom/datadog/android/flags/model/ResolutionDetails;
1817
public abstract fun resolveBooleanValue (Ljava/lang/String;Z)Z
@@ -59,11 +58,7 @@ public final class com/datadog/android/flags/FlagsConfiguration$Companion {
5958
}
6059

6160
public abstract interface class com/datadog/android/flags/FlagsStateListener {
62-
public abstract fun onStateChanged (Lcom/datadog/android/flags/model/FlagsClientState;Ljava/lang/Throwable;)V
63-
}
64-
65-
public final class com/datadog/android/flags/FlagsStateListener$DefaultImpls {
66-
public static synthetic fun onStateChanged$default (Lcom/datadog/android/flags/FlagsStateListener;Lcom/datadog/android/flags/model/FlagsClientState;Ljava/lang/Throwable;ILjava/lang/Object;)V
61+
public abstract fun onStateChanged (Lcom/datadog/android/flags/model/FlagsClientState;)V
6762
}
6863

6964
public final class com/datadog/android/flags/model/ErrorCode : java/lang/Enum {
@@ -181,13 +176,36 @@ public final class com/datadog/android/flags/model/ExposureEvent$Subject$Compani
181176
public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/ExposureEvent$Subject;
182177
}
183178

184-
public final class com/datadog/android/flags/model/FlagsClientState : java/lang/Enum {
185-
public static final field ERROR Lcom/datadog/android/flags/model/FlagsClientState;
186-
public static final field NOT_READY Lcom/datadog/android/flags/model/FlagsClientState;
187-
public static final field READY Lcom/datadog/android/flags/model/FlagsClientState;
188-
public static final field RECONCILING Lcom/datadog/android/flags/model/FlagsClientState;
189-
public static fun valueOf (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagsClientState;
190-
public static fun values ()[Lcom/datadog/android/flags/model/FlagsClientState;
179+
public abstract class com/datadog/android/flags/model/FlagsClientState {
180+
}
181+
182+
public final class com/datadog/android/flags/model/FlagsClientState$Error : com/datadog/android/flags/model/FlagsClientState {
183+
public fun <init> ()V
184+
public fun <init> (Ljava/lang/Throwable;)V
185+
public synthetic fun <init> (Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
186+
public final fun component1 ()Ljava/lang/Throwable;
187+
public final fun copy (Ljava/lang/Throwable;)Lcom/datadog/android/flags/model/FlagsClientState$Error;
188+
public static synthetic fun copy$default (Lcom/datadog/android/flags/model/FlagsClientState$Error;Ljava/lang/Throwable;ILjava/lang/Object;)Lcom/datadog/android/flags/model/FlagsClientState$Error;
189+
public fun equals (Ljava/lang/Object;)Z
190+
public final fun getError ()Ljava/lang/Throwable;
191+
public fun hashCode ()I
192+
public fun toString ()Ljava/lang/String;
193+
}
194+
195+
public final class com/datadog/android/flags/model/FlagsClientState$NotReady : com/datadog/android/flags/model/FlagsClientState {
196+
public static final field INSTANCE Lcom/datadog/android/flags/model/FlagsClientState$NotReady;
197+
}
198+
199+
public final class com/datadog/android/flags/model/FlagsClientState$Ready : com/datadog/android/flags/model/FlagsClientState {
200+
public static final field INSTANCE Lcom/datadog/android/flags/model/FlagsClientState$Ready;
201+
}
202+
203+
public final class com/datadog/android/flags/model/FlagsClientState$Reconciling : com/datadog/android/flags/model/FlagsClientState {
204+
public static final field INSTANCE Lcom/datadog/android/flags/model/FlagsClientState$Reconciling;
205+
}
206+
207+
public final class com/datadog/android/flags/model/FlagsClientState$Stale : com/datadog/android/flags/model/FlagsClientState {
208+
public static final field INSTANCE Lcom/datadog/android/flags/model/FlagsClientState$Stale;
191209
}
192210

193211
public final class com/datadog/android/flags/model/ResolutionDetails {

features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import java.util.concurrent.ExecutorService
1818
* methods are thread-safe and guarantee ordered delivery to listeners by using a
1919
* single-threaded executor service.
2020
*
21-
* State updates trigger listener notifications asynchronously on the executor service.
21+
* The current state is stored and emitted to new listeners immediately upon registration,
22+
* ensuring every listener receives the current state.
2223
*
2324
* @param subscription the underlying subscription for managing listeners
2425
* @param executorService single-threaded executor for ordered state notification delivery
@@ -27,16 +28,24 @@ internal class FlagsStateManager(
2728
private val subscription: DDCoreSubscription<FlagsStateListener>,
2829
private val executorService: ExecutorService
2930
) {
31+
/**
32+
* The current state of the client.
33+
* Thread-safe: uses volatile for visibility across threads.
34+
*/
35+
@Volatile
36+
private var currentState: FlagsClientState = FlagsClientState.NotReady
37+
3038
/**
3139
* Updates the state and notifies all listeners.
3240
*
33-
* This method asynchronously notifies all registered listeners on the executor service,
34-
* ensuring ordered delivery.
41+
* This method stores the new state and asynchronously notifies all registered listeners
42+
* on the executor service, ensuring ordered delivery.
3543
*
3644
* @param newState The new state to transition to.
3745
*/
3846
internal fun updateState(newState: FlagsClientState) {
3947
executorService.execute {
48+
currentState = newState
4049
subscription.notifyListeners {
4150
onStateChanged(newState)
4251
}
@@ -46,10 +55,19 @@ internal class FlagsStateManager(
4655
/**
4756
* Registers a listener to receive state change notifications.
4857
*
58+
* The listener will immediately receive the current state, then be notified
59+
* of all future state changes.
60+
*
4961
* @param listener The listener to add.
5062
*/
5163
fun addListener(listener: FlagsStateListener) {
5264
subscription.addListener(listener)
65+
66+
// Emit current state to new listener
67+
val state = currentState
68+
executorService.execute {
69+
listener.onStateChanged(state)
70+
}
5371
}
5472

5573
/**

features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ package com.datadog.android.flags.internal.evaluation
88

99
import com.datadog.android.api.InternalLogger
1010
import com.datadog.android.core.internal.utils.executeSafe
11-
import com.datadog.android.flags.internal.FlagsStateChannel
11+
import com.datadog.android.flags.internal.FlagsStateManager
1212
import com.datadog.android.flags.internal.net.PrecomputedAssignmentsReader
1313
import com.datadog.android.flags.internal.repository.FlagsRepository
1414
import com.datadog.android.flags.internal.repository.net.PrecomputeMapper
1515
import com.datadog.android.flags.model.EvaluationContext
16+
import com.datadog.android.flags.model.FlagsClientState
1617
import java.util.concurrent.ExecutorService
1718

1819
/**
@@ -27,15 +28,15 @@ import java.util.concurrent.ExecutorService
2728
* @param flagsRepository local storage for flag data and evaluation context
2829
* @param assignmentsReader handles reading assignments for the context.
2930
* @param precomputeMapper transforms network responses into internal flag format
30-
* @param flagStateChannel channel for notifying state change listeners
31+
* @param flagStateManager channel for notifying state change listeners
3132
*/
3233
internal class EvaluationsManager(
3334
private val executorService: ExecutorService,
3435
private val internalLogger: InternalLogger,
3536
private val flagsRepository: FlagsRepository,
3637
private val assignmentsReader: PrecomputedAssignmentsReader,
3738
private val precomputeMapper: PrecomputeMapper,
38-
private val flagStateChannel: FlagsStateChannel
39+
private val flagStateManager: FlagsStateManager
3940
) {
4041
/**
4142
* Processes a new evaluation context by fetching flags and storing atomically.
@@ -51,7 +52,7 @@ internal class EvaluationsManager(
5152
* a valid targeting key.
5253
*/
5354
fun updateEvaluationsForContext(context: EvaluationContext) {
54-
flagStateChannel.notifyReconciling()
55+
flagStateManager.updateState(FlagsClientState.Reconciling)
5556

5657
executorService.executeSafe(
5758
operationName = FETCH_AND_STORE_OPERATION_NAME,
@@ -63,29 +64,30 @@ internal class EvaluationsManager(
6364
{ "Processing evaluation context: ${context.targetingKey}" }
6465
)
6566

67+
val hadFlags = flagsRepository.hasFlags()
6668
val response = assignmentsReader.readPrecomputedFlags(context)
67-
val flagsMap = if (response != null) {
68-
precomputeMapper.map(response)
69+
if (response != null) {
70+
val flagsMap = precomputeMapper.map(response)
71+
flagsRepository.setFlagsAndContext(context, flagsMap)
72+
internalLogger.log(
73+
InternalLogger.Level.DEBUG,
74+
InternalLogger.Target.MAINTAINER,
75+
{ "Successfully processed context ${context.targetingKey} with ${flagsMap.size} flags" }
76+
)
77+
78+
flagStateManager.updateState(FlagsClientState.Ready)
6979
} else {
7080
internalLogger.log(
7181
InternalLogger.Level.WARN,
7282
InternalLogger.Target.USER,
7383
{ NETWORK_REQUEST_FAILED_MESSAGE }
7484
)
75-
emptyMap()
76-
}
77-
78-
flagsRepository.setFlagsAndContext(context, flagsMap)
79-
internalLogger.log(
80-
InternalLogger.Level.DEBUG,
81-
InternalLogger.Target.MAINTAINER,
82-
{ "Successfully processed context ${context.targetingKey} with ${flagsMap.size} flags" }
83-
)
8485

85-
if (response != null) {
86-
flagStateChannel.notifyReady()
87-
} else {
88-
flagStateChannel.notifyError()
86+
if (hadFlags) {
87+
flagStateManager.updateState(FlagsClientState.Stale)
88+
} else {
89+
flagStateManager.updateState(FlagsClientState.Error(Throwable(NETWORK_REQUEST_FAILED_MESSAGE)))
90+
}
8991
}
9092
}
9193
}

features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ internal class DefaultFlagsRepository(
8989

9090
override fun hasFlags(): Boolean = atomicState.get()?.flags?.isNotEmpty() ?: false
9191

92+
@Suppress("ReturnCount")
9293
override fun getPrecomputedFlagWithContext(key: String): Pair<PrecomputedFlag, EvaluationContext>? {
9394
waitForPersistenceLoad()
9495
val state = atomicState.get() ?: return null

features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import com.datadog.android.flags.internal.model.VariationType
1717
import com.datadog.android.flags.internal.repository.FlagsRepository
1818
import com.datadog.android.flags.model.ErrorCode
1919
import com.datadog.android.flags.model.EvaluationContext
20+
import com.datadog.android.flags.model.FlagsClientState
2021
import com.datadog.android.flags.model.ResolutionReason
2122
import com.datadog.android.flags.utils.forge.ForgeConfigurator
2223
import com.datadog.android.internal.utils.DDCoreSubscription
@@ -1332,22 +1333,23 @@ internal class DatadogFlagsClientTest {
13321333
testedClient.addStateListener(mockListener)
13331334

13341335
// Then
1335-
// No exception should be thrown
1336-
verifyNoInteractions(mockListener)
1336+
// Listener should immediately receive current state (NotReady)
1337+
verify(mockListener).onStateChanged(FlagsClientState.NotReady)
13371338
}
13381339

13391340
@Test
13401341
fun `M remove listener W removeStateListener()`() {
13411342
// Given
13421343
val mockListener = mock(FlagsStateListener::class.java)
13431344
testedClient.addStateListener(mockListener)
1345+
// Verify initial state was emitted
1346+
verify(mockListener).onStateChanged(FlagsClientState.NotReady)
13441347

13451348
// When
13461349
testedClient.removeStateListener(mockListener)
13471350

1348-
// Then
1349-
// No exception should be thrown
1350-
verifyNoInteractions(mockListener)
1351+
// Then - no exception should be thrown
1352+
org.mockito.kotlin.verifyNoMoreInteractions(mockListener)
13511353
}
13521354

13531355
// Note: updateState() tests removed - this is now an internal method called only by

features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateManagerTest.kt

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ import org.mockito.junit.jupiter.MockitoSettings
2020
import org.mockito.kotlin.any
2121
import org.mockito.kotlin.inOrder
2222
import org.mockito.kotlin.mock
23+
import org.mockito.kotlin.times
2324
import org.mockito.kotlin.verify
24-
import org.mockito.kotlin.verifyNoInteractions
2525
import org.mockito.kotlin.whenever
2626
import org.mockito.quality.Strictness
2727
import java.util.concurrent.ExecutorService
@@ -65,12 +65,19 @@ internal class FlagsStateManagerTest {
6565
testedManager.updateState(state)
6666

6767
// Then
68-
verify(mockListener).onStateChanged(state)
68+
if (state == FlagsClientState.NotReady) {
69+
// Special case: NotReady is both initial state and transition state
70+
verify(mockListener, times(2)).onStateChanged(FlagsClientState.NotReady)
71+
} else {
72+
inOrder(mockListener) {
73+
verify(mockListener).onStateChanged(FlagsClientState.NotReady) // Initial state on add
74+
verify(mockListener).onStateChanged(state) // State change
75+
}
76+
}
6977
}
7078

7179
// endregion
7280

73-
7481
// region addListener / removeListener
7582

7683
@Test
@@ -82,20 +89,26 @@ internal class FlagsStateManagerTest {
8289
testedManager.updateState(FlagsClientState.Ready)
8390

8491
// Then
85-
verify(mockListener).onStateChanged(FlagsClientState.Ready)
92+
inOrder(mockListener) {
93+
verify(mockListener).onStateChanged(FlagsClientState.NotReady) // Current state on add
94+
verify(mockListener).onStateChanged(FlagsClientState.Ready) // State update
95+
}
8696
}
8797

8898
@Test
89-
fun `M not notify listener W removeListener() and notify`() {
99+
fun `M not notify listener after removal W removeListener() and notify`() {
90100
// Given
91101
testedManager.addListener(mockListener)
102+
// Verify initial state was emitted
103+
verify(mockListener).onStateChanged(FlagsClientState.NotReady)
104+
92105
testedManager.removeListener(mockListener)
93106

94107
// When
95108
testedManager.updateState(FlagsClientState.Ready)
96109

97-
// Then
98-
verifyNoInteractions(mockListener)
110+
// Then - no further notifications after removal
111+
org.mockito.kotlin.verifyNoMoreInteractions(mockListener)
99112
}
100113

101114
@Test
@@ -109,8 +122,13 @@ internal class FlagsStateManagerTest {
109122
testedManager.updateState(FlagsClientState.Ready)
110123

111124
// Then
112-
verify(mockListener).onStateChanged(FlagsClientState.Ready)
113-
verify(mockListener2).onStateChanged(FlagsClientState.Ready)
125+
// Both listeners get current state on add, then Ready notification
126+
inOrder(mockListener, mockListener2) {
127+
verify(mockListener).onStateChanged(FlagsClientState.NotReady)
128+
verify(mockListener2).onStateChanged(FlagsClientState.NotReady)
129+
verify(mockListener).onStateChanged(FlagsClientState.Ready)
130+
verify(mockListener2).onStateChanged(FlagsClientState.Ready)
131+
}
114132
}
115133

116134
@Test
@@ -119,15 +137,14 @@ internal class FlagsStateManagerTest {
119137
testedManager.addListener(mockListener)
120138

121139
// When
122-
testedManager.updateState(FlagsClientState.NotReady)
123140
testedManager.updateState(FlagsClientState.Reconciling)
124141
testedManager.updateState(FlagsClientState.Ready)
125142

126143
// Then
127144
inOrder(mockListener) {
128-
verify(mockListener).onStateChanged(FlagsClientState.NotReady)
129-
verify(mockListener).onStateChanged(FlagsClientState.Reconciling)
130-
verify(mockListener).onStateChanged(FlagsClientState.Ready)
145+
verify(mockListener).onStateChanged(FlagsClientState.NotReady) // Initial on add
146+
verify(mockListener).onStateChanged(FlagsClientState.Reconciling) // Transition
147+
verify(mockListener).onStateChanged(FlagsClientState.Ready) // Transition
131148
}
132149
}
133150

0 commit comments

Comments
 (0)