From af48d4812a766242184bd6d0385311d06888d101 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Mon, 24 Nov 2025 10:37:30 -0700 Subject: [PATCH 01/28] Flags Client State enum --- .../android/flags/model/FlagsClientState.kt | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/model/FlagsClientState.kt diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/model/FlagsClientState.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/model/FlagsClientState.kt new file mode 100644 index 0000000000..30c8eaf6ff --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/model/FlagsClientState.kt @@ -0,0 +1,39 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.flags.model + +/** + * Represents the current state of a [com.datadog.android.flags.FlagsClient]. + * + * Note: A STALE state is not currently defined as there is no mechanism to determine + * whether configuration is stale. This may be added in a future release. + */ +enum class FlagsClientState { + /** + * The client has been created but no evaluation context has been set. + * No flags are available for evaluation in this state. + */ + NOT_READY, + + /** + * The client has successfully loaded flags and they are available for evaluation. + * This is the normal operational state. + */ + READY, + + /** + * The client is currently fetching new flags for a context change. + * Cached flags may still be available for evaluation during this state. + */ + RECONCILING, + + /** + * An unrecoverable error has occurred. + * The client cannot provide flag evaluations in this state. + */ + ERROR +} From a0cc0cceaa16fb80368fe09d6e64a35b4a176d9d Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Mon, 24 Nov 2025 10:37:42 -0700 Subject: [PATCH 02/28] Add flags-openfeature module to local-ci --- local_ci.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/local_ci.sh b/local_ci.sh index de4c660806..b4b3852f30 100755 --- a/local_ci.sh +++ b/local_ci.sh @@ -94,6 +94,7 @@ if [[ $CLEANUP == 1 ]]; then rm -rf dd-sdk-android-internal/build/ rm -rf dd-sdk-android-core/build/ rm -rf features/dd-sdk-android-flags/build/ + rm -rf features/dd-sdk-android-flags-openfeature/build/ rm -rf features/dd-sdk-android-logs/build/ rm -rf features/dd-sdk-android-ndk/build/ rm -rf features/dd-sdk-android-rum/build/ From 1d10ded06ef1dd722d397a388850b2420f988b7d Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Mon, 24 Nov 2025 10:51:47 -0700 Subject: [PATCH 03/28] Client State observer and reg/unreg --- .../com/datadog/android/flags/FlagsClient.kt | 30 +++++++++++++++++++ .../android/flags/FlagsStateObserver.kt | 27 +++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsStateObserver.kt diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt index 32ee125faa..2f376fb130 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt @@ -27,6 +27,7 @@ import com.datadog.android.flags.internal.repository.DefaultFlagsRepository import com.datadog.android.flags.internal.repository.NoOpFlagsRepository import com.datadog.android.flags.internal.repository.net.PrecomputeMapper import com.datadog.android.flags.model.EvaluationContext +import com.datadog.android.flags.model.FlagsClientState import com.datadog.android.flags.model.ResolutionDetails import org.json.JSONObject @@ -148,6 +149,35 @@ interface FlagsClient { */ fun resolve(flagKey: String, defaultValue: T): ResolutionDetails + /** + * Gets the current state of this [FlagsClient]. + * + * The state indicates whether the client is ready to evaluate flags, currently loading + * new flag values, or in an error state. + * + * @return The current [FlagsClientState]. + */ + fun getCurrentState(): FlagsClientState + + /** + * Registers an observer to receive state change notifications. + * + * The observer will be notified whenever the client's state changes (e.g., from NOT_READY + * to READY, or from READY to RECONCILING when the evaluation context changes). + * + * @param observer The [FlagsStateObserver] to register. + */ + fun addStateObserver(observer: FlagsStateObserver) + + /** + * Unregisters a previously registered state observer. + * + * After removal, the observer will no longer receive state change notifications. + * + * @param observer The [FlagsStateObserver] to unregister. + */ + fun removeStateObserver(observer: FlagsStateObserver) + /** * Builder for creating [FlagsClient] instances with custom configuration. * diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsStateObserver.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsStateObserver.kt new file mode 100644 index 0000000000..b6c216df68 --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsStateObserver.kt @@ -0,0 +1,27 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.flags + +import com.datadog.android.flags.model.FlagsClientState + +/** + * Observer interface for receiving state change notifications from a [FlagsClient]. + * + * Implementations of this interface can be registered with a [FlagsClient] to receive + * callbacks whenever the client's state changes (e.g., from NOT_READY to READY, or + * from READY to RECONCILING when the evaluation context changes). + */ +interface FlagsStateObserver { + /** + * Called when the state of the [FlagsClient] changes. + * + * @param newState The new state of the client. + * @param error Optional error that caused the state change. This is typically provided + * when transitioning to the [FlagsClientState.ERROR] state. + */ + fun onStateChanged(newState: FlagsClientState, error: Throwable? = null) +} From fe1b17249cc5d8e1f56b2ea8528877719d4daee6 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Mon, 24 Nov 2025 11:27:36 -0700 Subject: [PATCH 04/28] use DD core subscription, listeners, and implement new subscription methods --- .../com/datadog/android/flags/FlagsClient.kt | 16 +++--- ...StateObserver.kt => FlagsStateListener.kt} | 4 +- .../flags/internal/DatadogFlagsClient.kt | 55 +++++++++++++++++++ .../android/flags/internal/NoOpFlagsClient.kt | 24 ++++++++ 4 files changed, 89 insertions(+), 10 deletions(-) rename features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/{FlagsStateObserver.kt => FlagsStateListener.kt} (91%) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt index 2f376fb130..1e9ff4964f 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt @@ -160,23 +160,23 @@ interface FlagsClient { fun getCurrentState(): FlagsClientState /** - * Registers an observer to receive state change notifications. + * Registers a listener to receive state change notifications. * - * The observer will be notified whenever the client's state changes (e.g., from NOT_READY + * The listener will be notified whenever the client's state changes (e.g., from NOT_READY * to READY, or from READY to RECONCILING when the evaluation context changes). * - * @param observer The [FlagsStateObserver] to register. + * @param listener The [FlagsStateListener] to register. */ - fun addStateObserver(observer: FlagsStateObserver) + fun addStateListener(listener: FlagsStateListener) /** - * Unregisters a previously registered state observer. + * Unregisters a previously registered state listener. * - * After removal, the observer will no longer receive state change notifications. + * After removal, the listener will no longer receive state change notifications. * - * @param observer The [FlagsStateObserver] to unregister. + * @param listener The [FlagsStateListener] to unregister. */ - fun removeStateObserver(observer: FlagsStateObserver) + fun removeStateListener(listener: FlagsStateListener) /** * Builder for creating [FlagsClient] instances with custom configuration. diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsStateObserver.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsStateListener.kt similarity index 91% rename from features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsStateObserver.kt rename to features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsStateListener.kt index b6c216df68..1850629d1e 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsStateObserver.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsStateListener.kt @@ -9,13 +9,13 @@ package com.datadog.android.flags import com.datadog.android.flags.model.FlagsClientState /** - * Observer interface for receiving state change notifications from a [FlagsClient]. + * Listener interface for receiving state change notifications from a [FlagsClient]. * * Implementations of this interface can be registered with a [FlagsClient] to receive * callbacks whenever the client's state changes (e.g., from NOT_READY to READY, or * from READY to RECONCILING when the evaluation context changes). */ -interface FlagsStateObserver { +interface FlagsStateListener { /** * Called when the state of the [FlagsClient] changes. * diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt index 831c08a3f7..84178fc006 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt @@ -10,14 +10,18 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.flags.FlagsClient import com.datadog.android.flags.FlagsConfiguration +import com.datadog.android.flags.FlagsStateListener import com.datadog.android.flags.internal.evaluation.EvaluationsManager import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.internal.repository.FlagsRepository import com.datadog.android.flags.model.ErrorCode import com.datadog.android.flags.model.EvaluationContext +import com.datadog.android.flags.model.FlagsClientState import com.datadog.android.flags.model.ResolutionDetails import com.datadog.android.flags.model.ResolutionReason +import com.datadog.android.internal.utils.DDCoreSubscription import org.json.JSONObject +import java.util.concurrent.atomic.AtomicReference /** * Production implementation of [FlagsClient] that integrates with Datadog's flag evaluation system. @@ -46,6 +50,47 @@ internal class DatadogFlagsClient( private val processor: EventsProcessor ) : FlagsClient { + // region State Management + + /** + * The current state of this client. + * Thread-safe: uses atomic reference for lock-free reads and updates. + */ + private val currentState = AtomicReference(FlagsClientState.NOT_READY) + + /** + * Subscription for managing state change listeners. + * Thread-safe: DDCoreSubscription handles concurrent add/remove operations. + */ + private val stateListeners = DDCoreSubscription.create() + + /** + * Lock for ensuring ordered delivery of state change notifications. + * Synchronizes notification calls to guarantee listeners receive state changes in order. + */ + private val notificationLock = Any() + + /** + * Updates the client state and notifies all registered listeners. + * + * This method is thread-safe and guarantees that listeners receive state changes in order. + * Multiple concurrent calls to this method will be serialized to prevent out-of-order + * notifications. + * + * @param newState The new state to transition to. + * @param error Optional error that caused the state change. + */ + private fun updateState(newState: FlagsClientState, error: Throwable? = null) { + currentState.set(newState) + synchronized(notificationLock) { + stateListeners.notifyListeners { + onStateChanged(newState, error) + } + } + } + + // endregion + // region FlagsClient /** @@ -150,6 +195,16 @@ internal class DatadogFlagsClient( } } + override fun getCurrentState(): FlagsClientState = currentState.get() + + override fun addStateListener(listener: FlagsStateListener) { + stateListeners.addListener(listener) + } + + override fun removeStateListener(listener: FlagsStateListener) { + stateListeners.removeListener(listener) + } + // endregion // region Private Implementation diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt index d44571304c..dd636c2674 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt @@ -8,8 +8,10 @@ package com.datadog.android.flags.internal import com.datadog.android.api.InternalLogger import com.datadog.android.flags.FlagsClient +import com.datadog.android.flags.FlagsStateListener import com.datadog.android.flags.model.ErrorCode import com.datadog.android.flags.model.EvaluationContext +import com.datadog.android.flags.model.FlagsClientState import com.datadog.android.flags.model.ResolutionDetails import org.json.JSONObject @@ -114,6 +116,28 @@ internal class NoOpFlagsClient( return defaultValue } + /** + * Returns [FlagsClientState.ERROR] as this is a no-op client. + * @return Always [FlagsClientState.ERROR]. + */ + override fun getCurrentState(): FlagsClientState = FlagsClientState.ERROR + + /** + * No-op implementation that ignores listener registration. + * @param listener Ignored listener. + */ + override fun addStateListener(listener: FlagsStateListener) { + // No-op: listeners are not supported on NoOpFlagsClient + } + + /** + * No-op implementation that ignores listener removal. + * @param listener Ignored listener. + */ + override fun removeStateListener(listener: FlagsStateListener) { + // No-op: listeners are not supported on NoOpFlagsClient + } + /** * Logs an operation call on this NoOpFlagsClient using the policy-aware logging function. * This ensures visibility in both debug builds (MAINTAINER) and production (USER, if verbosity allows). From 803565053a5fdd9fc6bd0c346e4d46956dc048f8 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Mon, 24 Nov 2025 11:54:01 -0700 Subject: [PATCH 05/28] Flags State channel w/named state methods, listener paradigm and leveral core subscription class --- .../com/datadog/android/flags/FlagsClient.kt | 13 ++- .../flags/internal/DatadogFlagsClient.kt | 35 +++--- .../flags/internal/FlagsStateChannel.kt | 100 ++++++++++++++++++ .../internal/evaluation/EvaluationsManager.kt | 15 ++- 4 files changed, 137 insertions(+), 26 deletions(-) create mode 100644 features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateChannel.kt diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt index 1e9ff4964f..ee1c73180f 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt @@ -16,6 +16,7 @@ import com.datadog.android.core.InternalSdkCore import com.datadog.android.flags.internal.DatadogFlagsClient import com.datadog.android.flags.internal.DefaultRumEvaluationLogger import com.datadog.android.flags.internal.FlagsFeature +import com.datadog.android.flags.internal.FlagsStateChannel import com.datadog.android.flags.internal.LogWithPolicy import com.datadog.android.flags.internal.NoOpFlagsClient import com.datadog.android.flags.internal.NoOpRumEvaluationLogger @@ -29,6 +30,7 @@ import com.datadog.android.flags.internal.repository.net.PrecomputeMapper import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.flags.model.FlagsClientState import com.datadog.android.flags.model.ResolutionDetails +import com.datadog.android.internal.utils.DDCoreSubscription import org.json.JSONObject /** @@ -430,12 +432,18 @@ interface FlagsClient { val precomputeMapper = PrecomputeMapper(featureSdkCore.internalLogger) + // Create shared channel for state change notifications + val flagStateChannel = FlagsStateChannel( + subscription = DDCoreSubscription.create() + ) + val evaluationsManager = EvaluationsManager( executorService = executorService, internalLogger = featureSdkCore.internalLogger, flagsRepository = flagsRepository, assignmentsReader = assignmentsDownloader, - precomputeMapper = precomputeMapper + precomputeMapper = precomputeMapper, + flagStateChannel = flagStateChannel ) val rumEvaluationLogger = createRumEvaluationLogger(featureSdkCore) @@ -446,7 +454,8 @@ interface FlagsClient { flagsRepository = flagsRepository, flagsConfiguration = configuration, rumEvaluationLogger = rumEvaluationLogger, - processor = flagsFeature.processor + processor = flagsFeature.processor, + flagStateChannel = flagStateChannel ) } } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt index 84178fc006..af7fad2b7a 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt @@ -19,7 +19,6 @@ import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.flags.model.FlagsClientState import com.datadog.android.flags.model.ResolutionDetails import com.datadog.android.flags.model.ResolutionReason -import com.datadog.android.internal.utils.DDCoreSubscription import org.json.JSONObject import java.util.concurrent.atomic.AtomicReference @@ -39,6 +38,7 @@ import java.util.concurrent.atomic.AtomicReference * @param flagsConfiguration configuration for the flags feature * @param rumEvaluationLogger responsible for sending flag evaluations to RUM. * @param processor responsible for writing exposure batches to be sent to flags backend. + * @param flagStateChannel channel for managing state change listeners */ @Suppress("TooManyFunctions") // All functions are necessary for flag evaluation lifecycle internal class DatadogFlagsClient( @@ -47,7 +47,8 @@ internal class DatadogFlagsClient( private val flagsRepository: FlagsRepository, private val flagsConfiguration: FlagsConfiguration, private val rumEvaluationLogger: RumEvaluationLogger, - private val processor: EventsProcessor + private val processor: EventsProcessor, + private val flagStateChannel: FlagsStateChannel ) : FlagsClient { // region State Management @@ -58,34 +59,22 @@ internal class DatadogFlagsClient( */ private val currentState = AtomicReference(FlagsClientState.NOT_READY) - /** - * Subscription for managing state change listeners. - * Thread-safe: DDCoreSubscription handles concurrent add/remove operations. - */ - private val stateListeners = DDCoreSubscription.create() - - /** - * Lock for ensuring ordered delivery of state change notifications. - * Synchronizes notification calls to guarantee listeners receive state changes in order. - */ - private val notificationLock = Any() - /** * Updates the client state and notifies all registered listeners. * * This method is thread-safe and guarantees that listeners receive state changes in order. - * Multiple concurrent calls to this method will be serialized to prevent out-of-order - * notifications. + * The notification is delegated to [flagStateChannel] which handles synchronization. * * @param newState The new state to transition to. * @param error Optional error that caused the state change. */ - private fun updateState(newState: FlagsClientState, error: Throwable? = null) { + internal fun updateState(newState: FlagsClientState, error: Throwable? = null) { currentState.set(newState) - synchronized(notificationLock) { - stateListeners.notifyListeners { - onStateChanged(newState, error) - } + when (newState) { + FlagsClientState.NOT_READY -> flagStateChannel.notifyNotReady() + FlagsClientState.READY -> flagStateChannel.notifyReady() + FlagsClientState.RECONCILING -> flagStateChannel.notifyReconciling() + FlagsClientState.ERROR -> flagStateChannel.notifyError(error) } } @@ -198,11 +187,11 @@ internal class DatadogFlagsClient( override fun getCurrentState(): FlagsClientState = currentState.get() override fun addStateListener(listener: FlagsStateListener) { - stateListeners.addListener(listener) + flagStateChannel.addListener(listener) } override fun removeStateListener(listener: FlagsStateListener) { - stateListeners.removeListener(listener) + flagStateChannel.removeListener(listener) } // endregion diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateChannel.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateChannel.kt new file mode 100644 index 0000000000..dc293ec531 --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateChannel.kt @@ -0,0 +1,100 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.flags.internal + +import com.datadog.android.flags.FlagsStateListener +import com.datadog.android.flags.model.FlagsClientState +import com.datadog.android.internal.utils.DDCoreSubscription + +/** + * Channel for managing and notifying state change listeners. + * + * This class wraps [DDCoreSubscription] and provides semantic methods for each state + * transition, abstracting the underlying notification mechanism. All notification methods + * are thread-safe and guarantee ordered delivery to listeners. + * + * @param subscription the underlying subscription for managing listeners + * @param notificationLock lock for ensuring ordered delivery of state notifications + */ +internal class FlagsStateChannel( + private val subscription: DDCoreSubscription, + private val notificationLock: Any = Any() +) { + /** + * Notifies all listeners that the client has transitioned to NOT_READY state. + * + * This state indicates the client has been created but no evaluation context has been set. + */ + fun notifyNotReady() { + notifyState(FlagsClientState.NOT_READY, null) + } + + /** + * Notifies all listeners that the client has transitioned to READY state. + * + * This state indicates flags have been successfully loaded and are available for evaluation. + */ + fun notifyReady() { + notifyState(FlagsClientState.READY, null) + } + + /** + * Notifies all listeners that the client has transitioned to RECONCILING state. + * + * This state indicates the client is currently fetching new flags for a context change. + * Cached flags may still be available for evaluation during this state. + */ + fun notifyReconciling() { + notifyState(FlagsClientState.RECONCILING, null) + } + + /** + * Notifies all listeners that the client has transitioned to ERROR state. + * + * This state indicates an unrecoverable error has occurred. + * + * @param error the error that caused the transition to ERROR state, or null if unknown + */ + fun notifyError(error: Throwable? = null) { + notifyState(FlagsClientState.ERROR, error) + } + + /** + * Registers a listener to receive state change notifications. + * + * @param listener the listener to register + */ + fun addListener(listener: FlagsStateListener) { + subscription.addListener(listener) + } + + /** + * Unregisters a previously registered listener. + * + * @param listener the listener to unregister + */ + fun removeListener(listener: FlagsStateListener) { + subscription.removeListener(listener) + } + + /** + * Notifies all registered listeners of a state change. + * + * This method is thread-safe and guarantees ordered delivery. Multiple concurrent + * calls will be serialized to prevent out-of-order notifications. + * + * @param newState the new state + * @param error optional error associated with the state change + */ + private fun notifyState(newState: FlagsClientState, error: Throwable?) { + synchronized(notificationLock) { + subscription.notifyListeners { + onStateChanged(newState, error) + } + } + } +} diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt index 8ce7ea9289..e0f5f37fbe 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt @@ -8,6 +8,7 @@ package com.datadog.android.flags.internal.evaluation import com.datadog.android.api.InternalLogger import com.datadog.android.core.internal.utils.executeSafe +import com.datadog.android.flags.internal.FlagsStateChannel import com.datadog.android.flags.internal.net.PrecomputedAssignmentsReader import com.datadog.android.flags.internal.repository.FlagsRepository import com.datadog.android.flags.internal.repository.net.PrecomputeMapper @@ -26,13 +27,15 @@ import java.util.concurrent.ExecutorService * @param flagsRepository local storage for flag data and evaluation context * @param assignmentsReader handles reading assignments for the context. * @param precomputeMapper transforms network responses into internal flag format + * @param flagStateChannel channel for notifying state change listeners */ internal class EvaluationsManager( private val executorService: ExecutorService, private val internalLogger: InternalLogger, private val flagsRepository: FlagsRepository, private val assignmentsReader: PrecomputedAssignmentsReader, - private val precomputeMapper: PrecomputeMapper + private val precomputeMapper: PrecomputeMapper, + private val flagStateChannel: FlagsStateChannel ) { /** * Processes a new evaluation context by fetching flags and storing atomically. @@ -48,6 +51,9 @@ internal class EvaluationsManager( * a valid targeting key. */ fun updateEvaluationsForContext(context: EvaluationContext) { + // Transition to RECONCILING before starting the fetch operation + flagStateChannel.notifyReconciling() + executorService.executeSafe( operationName = FETCH_AND_STORE_OPERATION_NAME, internalLogger = internalLogger @@ -76,6 +82,13 @@ internal class EvaluationsManager( InternalLogger.Target.MAINTAINER, { "Successfully processed context ${context.targetingKey} with ${flagsMap.size} flags" } ) + + // Transition to READY after successful storage, or ERROR if fetch failed + if (response != null) { + flagStateChannel.notifyReady() + } else { + flagStateChannel.notifyError() + } } } From 4d1923c2a6042a5324a33f3d395840eca73cffc0 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Mon, 24 Nov 2025 22:46:10 -0700 Subject: [PATCH 06/28] tests --- .../android/flags/NoOpFlagsClientTest.kt | 41 +++++++ .../flags/internal/DatadogFlagsClientTest.kt | 105 +++++++++++++++++- .../evaluation/EvaluationsManagerTest.kt | 46 +++++++- 3 files changed, 190 insertions(+), 2 deletions(-) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt index dbfa04f9c8..eccf1e7cbb 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt @@ -11,6 +11,7 @@ import com.datadog.android.flags.internal.LogWithPolicy import com.datadog.android.flags.internal.NoOpFlagsClient import com.datadog.android.flags.model.ErrorCode import com.datadog.android.flags.model.EvaluationContext +import com.datadog.android.flags.model.FlagsClientState import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeExtension @@ -26,6 +27,7 @@ import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.argThat import org.mockito.kotlin.eq import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions import org.mockito.quality.Strictness @ExtendWith(MockitoExtension::class, ForgeExtension::class) @@ -425,4 +427,43 @@ internal class NoOpFlagsClientTest { } // endregion + + // region State Management + + @Test + fun `M return ERROR state W getCurrentState()`() { + // When + val result = testedClient.getCurrentState() + + // Then + assertThat(result).isEqualTo(FlagsClientState.ERROR) + } + + @Test + fun `M do nothing W addStateListener()`() { + // Given + val mockListener = org.mockito.Mockito.mock(FlagsStateListener::class.java) + + // When + testedClient.addStateListener(mockListener) + + // Then + // No exception should be thrown, method should be no-op + verifyNoInteractions(mockListener) + } + + @Test + fun `M do nothing W removeStateListener()`() { + // Given + val mockListener = org.mockito.Mockito.mock(FlagsStateListener::class.java) + + // When + testedClient.removeStateListener(mockListener) + + // Then + // No exception should be thrown, method should be no-op + verifyNoInteractions(mockListener) + } + + // endregion } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt index bc72f5a069..887bf4e147 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt @@ -10,14 +10,17 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.Feature.Companion.RUM_FEATURE_NAME import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.flags.FlagsConfiguration +import com.datadog.android.flags.FlagsStateListener import com.datadog.android.flags.internal.evaluation.EvaluationsManager import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.internal.model.VariationType import com.datadog.android.flags.internal.repository.FlagsRepository import com.datadog.android.flags.model.ErrorCode import com.datadog.android.flags.model.EvaluationContext +import com.datadog.android.flags.model.FlagsClientState import com.datadog.android.flags.model.ResolutionReason import com.datadog.android.flags.utils.forge.ForgeConfigurator +import com.datadog.android.internal.utils.DDCoreSubscription import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration @@ -97,7 +100,8 @@ internal class DatadogFlagsClientTest { rumIntegrationEnabled = true ), rumEvaluationLogger = mockRumEvaluationLogger, - processor = mockProcessor + processor = mockProcessor, + flagStateChannel = FlagsStateChannel(DDCoreSubscription.create()) ) } @@ -1292,4 +1296,103 @@ internal class DatadogFlagsClientTest { } // endregion + + // region State Management + + @Test + fun `M return NOT_READY W getCurrentState() {initial state}`() { + // When + val result = testedClient.getCurrentState() + + // Then + assertThat(result).isEqualTo(FlagsClientState.NOT_READY) + } + + @Test + fun `M add listener W addStateListener()`() { + // Given + val mockListener = mock(FlagsStateListener::class.java) + + // When + testedClient.addStateListener(mockListener) + + // Then + // No exception should be thrown + verifyNoInteractions(mockListener) + } + + @Test + fun `M remove listener W removeStateListener()`() { + // Given + val mockListener = mock(FlagsStateListener::class.java) + testedClient.addStateListener(mockListener) + + // When + testedClient.removeStateListener(mockListener) + + // Then + // No exception should be thrown + verifyNoInteractions(mockListener) + } + + @Test + fun `M notify listener W updateState() called`() { + // Given + val mockListener = mock(FlagsStateListener::class.java) + testedClient.addStateListener(mockListener) + + // When + testedClient.updateState(FlagsClientState.READY, null) + + // Then + verify(mockListener).onStateChanged(FlagsClientState.READY, null) + assertThat(testedClient.getCurrentState()).isEqualTo(FlagsClientState.READY) + } + + @Test + fun `M notify listener with error W updateState(ERROR) called`() { + // Given + val mockListener = mock(FlagsStateListener::class.java) + val fakeError = RuntimeException("Test error") + testedClient.addStateListener(mockListener) + + // When + testedClient.updateState(FlagsClientState.ERROR, fakeError) + + // Then + verify(mockListener).onStateChanged(FlagsClientState.ERROR, fakeError) + assertThat(testedClient.getCurrentState()).isEqualTo(FlagsClientState.ERROR) + } + + @Test + fun `M notify all listeners W updateState() with multiple listeners`() { + // Given + val mockListener1 = mock(FlagsStateListener::class.java) + val mockListener2 = mock(FlagsStateListener::class.java) + testedClient.addStateListener(mockListener1) + testedClient.addStateListener(mockListener2) + + // When + testedClient.updateState(FlagsClientState.RECONCILING, null) + + // Then + verify(mockListener1).onStateChanged(FlagsClientState.RECONCILING, null) + verify(mockListener2).onStateChanged(FlagsClientState.RECONCILING, null) + } + + @Test + fun `M not notify removed listener W removeStateListener() then updateState()`() { + // Given + val mockListener = mock(FlagsStateListener::class.java) + testedClient.addStateListener(mockListener) + testedClient.removeStateListener(mockListener) + + // When + testedClient.updateState(FlagsClientState.READY, null) + + // Then + verifyNoInteractions(mockListener) + } + + // endregion } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt index 44469165ce..9c9050aed3 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt @@ -7,6 +7,7 @@ package com.datadog.android.flags.internal.evaluation import com.datadog.android.api.InternalLogger +import com.datadog.android.flags.internal.FlagsStateChannel import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.internal.net.PrecomputedAssignmentsReader import com.datadog.android.flags.internal.repository.FlagsRepository @@ -53,6 +54,9 @@ internal class EvaluationsManagerTest { @Mock lateinit var mockPrecomputeMapper: PrecomputeMapper + @Mock + lateinit var mockFlagsStateChannel: FlagsStateChannel + @StringForgery lateinit var fakeTargetingKey: String @@ -75,7 +79,8 @@ internal class EvaluationsManagerTest { internalLogger = mockInternalLogger, flagsRepository = mockFlagsRepository, assignmentsReader = mockAssignmentsDownloader, - precomputeMapper = mockPrecomputeMapper + precomputeMapper = mockPrecomputeMapper, + flagStateChannel = mockFlagsStateChannel ) // Mock executor to run tasks synchronously for testing @@ -218,4 +223,43 @@ internal class EvaluationsManagerTest { assertThat(logCaptor.firstValue.invoke()).contains("Processing evaluation context: $fakeTargetingKey") } + + // region State Transitions + + @Test + fun `M notify RECONCILING then READY W updateEvaluationsForContext() { successful fetch }`() { + // Given + val publicContext = EvaluationContext(fakeTargetingKey, emptyMap()) + val mockResponse = "{}" + val expectedFlags = mapOf() + + whenever(mockAssignmentsDownloader.readPrecomputedFlags(publicContext)).thenReturn(mockResponse) + whenever(mockPrecomputeMapper.map(mockResponse)).thenReturn(expectedFlags) + + // When + evaluationsManager.updateEvaluationsForContext(publicContext) + + // Then + val inOrder = org.mockito.kotlin.inOrder(mockFlagsStateChannel) + inOrder.verify(mockFlagsStateChannel).notifyReconciling() + inOrder.verify(mockFlagsStateChannel).notifyReady() + } + + @Test + fun `M notify RECONCILING then ERROR W updateEvaluationsForContext() { network failure }`() { + // Given + val publicContext = EvaluationContext(fakeTargetingKey, emptyMap()) + + whenever(mockAssignmentsDownloader.readPrecomputedFlags(publicContext)).thenReturn(null) + + // When + evaluationsManager.updateEvaluationsForContext(publicContext) + + // Then + val inOrder = org.mockito.kotlin.inOrder(mockFlagsStateChannel) + inOrder.verify(mockFlagsStateChannel).notifyReconciling() + inOrder.verify(mockFlagsStateChannel).notifyError() + } + + // endregion } From 856631b1be8edbfe806efed981107c51afca79b9 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Mon, 24 Nov 2025 22:48:36 -0700 Subject: [PATCH 07/28] api surface --- features/dd-sdk-android-flags/api/apiSurface | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/features/dd-sdk-android-flags/api/apiSurface b/features/dd-sdk-android-flags/api/apiSurface index 326ce2e2ea..57eaeba8bf 100644 --- a/features/dd-sdk-android-flags/api/apiSurface +++ b/features/dd-sdk-android-flags/api/apiSurface @@ -8,6 +8,9 @@ interface com.datadog.android.flags.FlagsClient fun resolveIntValue(String, Int): Int fun resolveStructureValue(String, org.json.JSONObject): org.json.JSONObject fun resolve(String, T): com.datadog.android.flags.model.ResolutionDetails + fun getCurrentState(): com.datadog.android.flags.model.FlagsClientState + fun addStateListener(FlagsStateListener) + fun removeStateListener(FlagsStateListener) class Builder constructor(String = DEFAULT_CLIENT_NAME, com.datadog.android.api.SdkCore = Datadog.getInstance()) fun build(): FlagsClient @@ -22,6 +25,8 @@ data class com.datadog.android.flags.FlagsConfiguration fun gracefulModeEnabled(Boolean): Builder fun build(): FlagsConfiguration companion object +interface com.datadog.android.flags.FlagsStateListener + fun onStateChanged(com.datadog.android.flags.model.FlagsClientState, Throwable? = null) enum com.datadog.android.flags.model.ErrorCode - PROVIDER_NOT_READY - FLAG_NOT_FOUND @@ -29,6 +34,11 @@ enum com.datadog.android.flags.model.ErrorCode - TYPE_MISMATCH data class com.datadog.android.flags.model.EvaluationContext constructor(String, Map = emptyMap()) +enum com.datadog.android.flags.model.FlagsClientState + - NOT_READY + - READY + - RECONCILING + - ERROR data class com.datadog.android.flags.model.ResolutionDetails constructor(T, String? = null, ResolutionReason? = null, ErrorCode? = null, String? = null, Map = emptyMap()) enum com.datadog.android.flags.model.ResolutionReason From c9f9cbcd6c4406c8281671d8ff82ba7c5f7fe20b Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Mon, 24 Nov 2025 22:52:20 -0700 Subject: [PATCH 08/28] trim comments --- .../kotlin/com/datadog/android/flags/FlagsClient.kt | 1 - .../android/flags/internal/FlagsStateChannel.kt | 10 ---------- .../datadog/android/flags/internal/NoOpFlagsClient.kt | 2 -- .../flags/internal/evaluation/EvaluationsManager.kt | 2 -- 4 files changed, 15 deletions(-) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt index ee1c73180f..bc59784cf7 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt @@ -432,7 +432,6 @@ interface FlagsClient { val precomputeMapper = PrecomputeMapper(featureSdkCore.internalLogger) - // Create shared channel for state change notifications val flagStateChannel = FlagsStateChannel( subscription = DDCoreSubscription.create() ) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateChannel.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateChannel.kt index dc293ec531..2308ae0b4f 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateChannel.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateChannel.kt @@ -63,20 +63,10 @@ internal class FlagsStateChannel( notifyState(FlagsClientState.ERROR, error) } - /** - * Registers a listener to receive state change notifications. - * - * @param listener the listener to register - */ fun addListener(listener: FlagsStateListener) { subscription.addListener(listener) } - /** - * Unregisters a previously registered listener. - * - * @param listener the listener to unregister - */ fun removeListener(listener: FlagsStateListener) { subscription.removeListener(listener) } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt index dd636c2674..4df6067544 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt @@ -127,7 +127,6 @@ internal class NoOpFlagsClient( * @param listener Ignored listener. */ override fun addStateListener(listener: FlagsStateListener) { - // No-op: listeners are not supported on NoOpFlagsClient } /** @@ -135,7 +134,6 @@ internal class NoOpFlagsClient( * @param listener Ignored listener. */ override fun removeStateListener(listener: FlagsStateListener) { - // No-op: listeners are not supported on NoOpFlagsClient } /** diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt index e0f5f37fbe..e655e64043 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt @@ -51,7 +51,6 @@ internal class EvaluationsManager( * a valid targeting key. */ fun updateEvaluationsForContext(context: EvaluationContext) { - // Transition to RECONCILING before starting the fetch operation flagStateChannel.notifyReconciling() executorService.executeSafe( @@ -83,7 +82,6 @@ internal class EvaluationsManager( { "Successfully processed context ${context.targetingKey} with ${flagsMap.size} flags" } ) - // Transition to READY after successful storage, or ERROR if fetch failed if (response != null) { flagStateChannel.notifyReady() } else { From a44a582e0ab1e14dfe1544562c6b772bd9d2496d Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Mon, 24 Nov 2025 23:06:40 -0700 Subject: [PATCH 09/28] detekt safe --- detekt_custom_safe_calls.yml | 2 + .../flags/internal/FlagsStateChannelTest.kt | 179 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateChannelTest.kt diff --git a/detekt_custom_safe_calls.yml b/detekt_custom_safe_calls.yml index dc34e29760..69ed988d91 100644 --- a/detekt_custom_safe_calls.yml +++ b/detekt_custom_safe_calls.yml @@ -449,6 +449,7 @@ datadog: - "java.util.concurrent.atomic.AtomicReference.constructor(com.datadog.android.api.SdkCore?)" - "java.util.concurrent.atomic.AtomicReference.constructor(com.datadog.android.api.feature.FeatureEventReceiver?)" - "java.util.concurrent.atomic.AtomicReference.constructor(com.datadog.android.flags.internal.repository.DefaultFlagsRepository.FlagsState?)" + - "java.util.concurrent.atomic.AtomicReference.constructor(com.datadog.android.flags.model.FlagsClientState?)" - "java.util.concurrent.atomic.AtomicReference.constructor(com.datadog.android.flags.model.ProviderContext?)" - "java.util.concurrent.atomic.AtomicReference.constructor(com.datadog.android.rum.internal.domain.RumContext?)" - "java.util.concurrent.atomic.AtomicReference.constructor(kotlin.collections.Map?)" @@ -461,6 +462,7 @@ datadog: - "java.util.concurrent.atomic.AtomicReference.set(com.datadog.android.rum.internal.domain.RumContext?)" - "java.util.concurrent.atomic.AtomicReference.set(com.datadog.android.trace.api.tracer.DatadogTracer?)" - "java.util.concurrent.atomic.AtomicReference.set(com.datadog.android.flags.internal.repository.DefaultFlagsRepository.FlagsState?)" + - "java.util.concurrent.atomic.AtomicReference.set(com.datadog.android.flags.model.FlagsClientState?)" - "java.util.concurrent.atomic.AtomicReference.set(com.datadog.android.flags.model.ProviderContext?)" - "java.util.concurrent.atomic.AtomicReference.set(com.datadog.trace.bootstrap.instrumentation.api.AgentTracer.TracerAPI?)" - "java.util.concurrent.atomic.AtomicReference.set(com.datadog.trace.core.CoreTracer?)" diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateChannelTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateChannelTest.kt new file mode 100644 index 0000000000..51e1676242 --- /dev/null +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateChannelTest.kt @@ -0,0 +1,179 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.flags.internal + +import com.datadog.android.flags.FlagsStateListener +import com.datadog.android.flags.model.FlagsClientState +import com.datadog.android.internal.utils.DDCoreSubscription +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.quality.Strictness + +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class FlagsStateChannelTest { + + @Mock + lateinit var mockListener: FlagsStateListener + + private lateinit var testedChannel: FlagsStateChannel + + @BeforeEach + fun `set up`() { + testedChannel = FlagsStateChannel(DDCoreSubscription.create()) + } + + // region notifyNotReady + + @Test + fun `M notify listeners with NOT_READY W notifyNotReady()`() { + // Given + testedChannel.addListener(mockListener) + + // When + testedChannel.notifyNotReady() + + // Then + verify(mockListener).onStateChanged(FlagsClientState.NOT_READY, null) + } + + // endregion + + // region notifyReady + + @Test + fun `M notify listeners with READY W notifyReady()`() { + // Given + testedChannel.addListener(mockListener) + + // When + testedChannel.notifyReady() + + // Then + verify(mockListener).onStateChanged(FlagsClientState.READY, null) + } + + // endregion + + // region notifyReconciling + + @Test + fun `M notify listeners with RECONCILING W notifyReconciling()`() { + // Given + testedChannel.addListener(mockListener) + + // When + testedChannel.notifyReconciling() + + // Then + verify(mockListener).onStateChanged(FlagsClientState.RECONCILING, null) + } + + // endregion + + // region notifyError + + @Test + fun `M notify listeners with ERROR and null W notifyError() {no error provided}`() { + // Given + testedChannel.addListener(mockListener) + + // When + testedChannel.notifyError() + + // Then + verify(mockListener).onStateChanged(FlagsClientState.ERROR, null) + } + + @Test + fun `M notify listeners with ERROR and throwable W notifyError() {error provided}`() { + // Given + testedChannel.addListener(mockListener) + val fakeError = RuntimeException("Test error") + + // When + testedChannel.notifyError(fakeError) + + // Then + verify(mockListener).onStateChanged(FlagsClientState.ERROR, fakeError) + } + + // endregion + + // region addListener / removeListener + + @Test + fun `M notify listener W addListener() and notify`() { + // Given + testedChannel.addListener(mockListener) + + // When + testedChannel.notifyReady() + + // Then + verify(mockListener).onStateChanged(FlagsClientState.READY, null) + } + + @Test + fun `M not notify listener W removeListener() and notify`() { + // Given + testedChannel.addListener(mockListener) + testedChannel.removeListener(mockListener) + + // When + testedChannel.notifyReady() + + // Then + verifyNoInteractions(mockListener) + } + + @Test + fun `M notify all listeners W multiple listeners registered`() { + // Given + val mockListener2 = org.mockito.Mockito.mock(FlagsStateListener::class.java) + testedChannel.addListener(mockListener) + testedChannel.addListener(mockListener2) + + // When + testedChannel.notifyReady() + + // Then + verify(mockListener).onStateChanged(FlagsClientState.READY, null) + verify(mockListener2).onStateChanged(FlagsClientState.READY, null) + } + + @Test + fun `M notify listeners in order W multiple state transitions`() { + // Given + testedChannel.addListener(mockListener) + + // When + testedChannel.notifyNotReady() + testedChannel.notifyReconciling() + testedChannel.notifyReady() + + // Then + val captor = argumentCaptor() + verify(mockListener, org.mockito.kotlin.times(3)).onStateChanged(captor.capture(), org.mockito.kotlin.any()) + + assertThat(captor.allValues).containsExactly( + FlagsClientState.NOT_READY, + FlagsClientState.RECONCILING, + FlagsClientState.READY + ) + } + + // endregion +} From 2ffdd582af9145b27d6350ef278d990916dc2b2e Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Mon, 24 Nov 2025 23:49:53 -0700 Subject: [PATCH 10/28] api --- .../api/dd-sdk-android-flags.api | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api b/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api index c29caad72e..96d9ca19fb 100644 --- a/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api +++ b/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api @@ -8,9 +8,12 @@ public final class com/datadog/android/flags/Flags { public abstract interface class com/datadog/android/flags/FlagsClient { public static final field Companion Lcom/datadog/android/flags/FlagsClient$Companion; + public abstract fun addStateListener (Lcom/datadog/android/flags/FlagsStateListener;)V public static fun get ()Lcom/datadog/android/flags/FlagsClient; public static fun get (Ljava/lang/String;)Lcom/datadog/android/flags/FlagsClient; public static fun get (Ljava/lang/String;Lcom/datadog/android/api/SdkCore;)Lcom/datadog/android/flags/FlagsClient; + public abstract fun getCurrentState ()Lcom/datadog/android/flags/model/FlagsClientState; + public abstract fun removeStateListener (Lcom/datadog/android/flags/FlagsStateListener;)V public abstract fun resolve (Ljava/lang/String;Ljava/lang/Object;)Lcom/datadog/android/flags/model/ResolutionDetails; public abstract fun resolveBooleanValue (Ljava/lang/String;Z)Z public abstract fun resolveDoubleValue (Ljava/lang/String;D)D @@ -55,6 +58,14 @@ public final class com/datadog/android/flags/FlagsConfiguration$Builder { public final class com/datadog/android/flags/FlagsConfiguration$Companion { } +public abstract interface class com/datadog/android/flags/FlagsStateListener { + public abstract fun onStateChanged (Lcom/datadog/android/flags/model/FlagsClientState;Ljava/lang/Throwable;)V +} + +public final class com/datadog/android/flags/FlagsStateListener$DefaultImpls { + public static synthetic fun onStateChanged$default (Lcom/datadog/android/flags/FlagsStateListener;Lcom/datadog/android/flags/model/FlagsClientState;Ljava/lang/Throwable;ILjava/lang/Object;)V +} + public final class com/datadog/android/flags/model/ErrorCode : java/lang/Enum { public static final field FLAG_NOT_FOUND Lcom/datadog/android/flags/model/ErrorCode; public static final field PARSE_ERROR Lcom/datadog/android/flags/model/ErrorCode; @@ -170,6 +181,15 @@ public final class com/datadog/android/flags/model/ExposureEvent$Subject$Compani public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/ExposureEvent$Subject; } +public final class com/datadog/android/flags/model/FlagsClientState : java/lang/Enum { + public static final field ERROR Lcom/datadog/android/flags/model/FlagsClientState; + public static final field NOT_READY Lcom/datadog/android/flags/model/FlagsClientState; + public static final field READY Lcom/datadog/android/flags/model/FlagsClientState; + public static final field RECONCILING Lcom/datadog/android/flags/model/FlagsClientState; + public static fun valueOf (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagsClientState; + public static fun values ()[Lcom/datadog/android/flags/model/FlagsClientState; +} + public final class com/datadog/android/flags/model/ResolutionDetails { public fun (Ljava/lang/Object;Ljava/lang/String;Lcom/datadog/android/flags/model/ResolutionReason;Lcom/datadog/android/flags/model/ErrorCode;Ljava/lang/String;Ljava/util/Map;)V public synthetic fun (Ljava/lang/Object;Ljava/lang/String;Lcom/datadog/android/flags/model/ResolutionReason;Lcom/datadog/android/flags/model/ErrorCode;Ljava/lang/String;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V From d6d65381243f8440576d4e21b414fcf6ac8134f0 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 27 Nov 2025 00:00:05 -0700 Subject: [PATCH 11/28] refactor into FlagsStateManager --- .../android/flags/FlagsStateListener.kt | 10 +- .../flags/internal/FlagsStateChannel.kt | 90 --------- .../flags/internal/FlagsStateManager.kt | 63 ++++++ .../android/flags/model/FlagsClientState.kt | 21 +- .../flags/internal/FlagsStateChannelTest.kt | 179 ------------------ .../flags/internal/FlagsStateManagerTest.kt | 147 ++++++++++++++ 6 files changed, 227 insertions(+), 283 deletions(-) delete mode 100644 features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateChannel.kt create mode 100644 features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt delete mode 100644 features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateChannelTest.kt create mode 100644 features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateManagerTest.kt diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsStateListener.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsStateListener.kt index 1850629d1e..7cb15873d3 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsStateListener.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsStateListener.kt @@ -12,16 +12,14 @@ import com.datadog.android.flags.model.FlagsClientState * Listener interface for receiving state change notifications from a [FlagsClient]. * * Implementations of this interface can be registered with a [FlagsClient] to receive - * callbacks whenever the client's state changes (e.g., from NOT_READY to READY, or - * from READY to RECONCILING when the evaluation context changes). + * callbacks whenever the client's state changes. */ interface FlagsStateListener { /** * Called when the state of the [FlagsClient] changes. * - * @param newState The new state of the client. - * @param error Optional error that caused the state change. This is typically provided - * when transitioning to the [FlagsClientState.ERROR] state. + * @param newState The new state of the client. If the state is [FlagsClientState.Error], + * the error details are contained within the state object itself. */ - fun onStateChanged(newState: FlagsClientState, error: Throwable? = null) + fun onStateChanged(newState: FlagsClientState) } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateChannel.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateChannel.kt deleted file mode 100644 index 2308ae0b4f..0000000000 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateChannel.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.android.flags.internal - -import com.datadog.android.flags.FlagsStateListener -import com.datadog.android.flags.model.FlagsClientState -import com.datadog.android.internal.utils.DDCoreSubscription - -/** - * Channel for managing and notifying state change listeners. - * - * This class wraps [DDCoreSubscription] and provides semantic methods for each state - * transition, abstracting the underlying notification mechanism. All notification methods - * are thread-safe and guarantee ordered delivery to listeners. - * - * @param subscription the underlying subscription for managing listeners - * @param notificationLock lock for ensuring ordered delivery of state notifications - */ -internal class FlagsStateChannel( - private val subscription: DDCoreSubscription, - private val notificationLock: Any = Any() -) { - /** - * Notifies all listeners that the client has transitioned to NOT_READY state. - * - * This state indicates the client has been created but no evaluation context has been set. - */ - fun notifyNotReady() { - notifyState(FlagsClientState.NOT_READY, null) - } - - /** - * Notifies all listeners that the client has transitioned to READY state. - * - * This state indicates flags have been successfully loaded and are available for evaluation. - */ - fun notifyReady() { - notifyState(FlagsClientState.READY, null) - } - - /** - * Notifies all listeners that the client has transitioned to RECONCILING state. - * - * This state indicates the client is currently fetching new flags for a context change. - * Cached flags may still be available for evaluation during this state. - */ - fun notifyReconciling() { - notifyState(FlagsClientState.RECONCILING, null) - } - - /** - * Notifies all listeners that the client has transitioned to ERROR state. - * - * This state indicates an unrecoverable error has occurred. - * - * @param error the error that caused the transition to ERROR state, or null if unknown - */ - fun notifyError(error: Throwable? = null) { - notifyState(FlagsClientState.ERROR, error) - } - - fun addListener(listener: FlagsStateListener) { - subscription.addListener(listener) - } - - fun removeListener(listener: FlagsStateListener) { - subscription.removeListener(listener) - } - - /** - * Notifies all registered listeners of a state change. - * - * This method is thread-safe and guarantees ordered delivery. Multiple concurrent - * calls will be serialized to prevent out-of-order notifications. - * - * @param newState the new state - * @param error optional error associated with the state change - */ - private fun notifyState(newState: FlagsClientState, error: Throwable?) { - synchronized(notificationLock) { - subscription.notifyListeners { - onStateChanged(newState, error) - } - } - } -} diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt new file mode 100644 index 0000000000..2448cc5e52 --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt @@ -0,0 +1,63 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.flags.internal + +import com.datadog.android.flags.FlagsStateListener +import com.datadog.android.flags.model.FlagsClientState +import com.datadog.android.internal.utils.DDCoreSubscription +import java.util.concurrent.ExecutorService + +/** + * Manages state transitions and notifications for a [com.datadog.android.flags.FlagsClient]. + * + * This class handles state change notifications to registered listeners. All notification + * methods are thread-safe and guarantee ordered delivery to listeners by using a + * single-threaded executor service. + * + * State updates trigger listener notifications asynchronously on the executor service. + * + * @param subscription the underlying subscription for managing listeners + * @param executorService single-threaded executor for ordered state notification delivery + */ +internal class FlagsStateManager( + private val subscription: DDCoreSubscription, + private val executorService: ExecutorService +) { + /** + * Updates the state and notifies all listeners. + * + * This method asynchronously notifies all registered listeners on the executor service, + * ensuring ordered delivery. + * + * @param newState The new state to transition to. + */ + internal fun updateState(newState: FlagsClientState) { + executorService.execute { + subscription.notifyListeners { + onStateChanged(newState) + } + } + } + + /** + * Registers a listener to receive state change notifications. + * + * @param listener The listener to add. + */ + fun addListener(listener: FlagsStateListener) { + subscription.addListener(listener) + } + + /** + * Unregisters a previously registered listener. + * + * @param listener The listener to remove. + */ + fun removeListener(listener: FlagsStateListener) { + subscription.removeListener(listener) + } +} diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/model/FlagsClientState.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/model/FlagsClientState.kt index 30c8eaf6ff..187275a6b5 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/model/FlagsClientState.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/model/FlagsClientState.kt @@ -8,32 +8,37 @@ package com.datadog.android.flags.model /** * Represents the current state of a [com.datadog.android.flags.FlagsClient]. - * - * Note: A STALE state is not currently defined as there is no mechanism to determine - * whether configuration is stale. This may be added in a future release. */ -enum class FlagsClientState { +sealed class FlagsClientState { /** * The client has been created but no evaluation context has been set. * No flags are available for evaluation in this state. */ - NOT_READY, + object NotReady : FlagsClientState() /** * The client has successfully loaded flags and they are available for evaluation. * This is the normal operational state. */ - READY, + object Ready : FlagsClientState() /** * The client is currently fetching new flags for a context change. * Cached flags may still be available for evaluation during this state. */ - RECONCILING, + object Reconciling : FlagsClientState() + + /** + * The client is currently stale. + * Cached flags may still be available for evaluation during this state. + */ + object Stale : FlagsClientState() /** * An unrecoverable error has occurred. * The client cannot provide flag evaluations in this state. + * + * @param error The error that caused the transition to this state, or null if unknown. */ - ERROR + data class Error(val error: Throwable? = null) : FlagsClientState() } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateChannelTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateChannelTest.kt deleted file mode 100644 index 51e1676242..0000000000 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateChannelTest.kt +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.android.flags.internal - -import com.datadog.android.flags.FlagsStateListener -import com.datadog.android.flags.model.FlagsClientState -import com.datadog.android.internal.utils.DDCoreSubscription -import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith -import org.mockito.Mock -import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.junit.jupiter.MockitoSettings -import org.mockito.kotlin.argumentCaptor -import org.mockito.kotlin.verify -import org.mockito.kotlin.verifyNoInteractions -import org.mockito.quality.Strictness - -@ExtendWith(MockitoExtension::class) -@MockitoSettings(strictness = Strictness.LENIENT) -internal class FlagsStateChannelTest { - - @Mock - lateinit var mockListener: FlagsStateListener - - private lateinit var testedChannel: FlagsStateChannel - - @BeforeEach - fun `set up`() { - testedChannel = FlagsStateChannel(DDCoreSubscription.create()) - } - - // region notifyNotReady - - @Test - fun `M notify listeners with NOT_READY W notifyNotReady()`() { - // Given - testedChannel.addListener(mockListener) - - // When - testedChannel.notifyNotReady() - - // Then - verify(mockListener).onStateChanged(FlagsClientState.NOT_READY, null) - } - - // endregion - - // region notifyReady - - @Test - fun `M notify listeners with READY W notifyReady()`() { - // Given - testedChannel.addListener(mockListener) - - // When - testedChannel.notifyReady() - - // Then - verify(mockListener).onStateChanged(FlagsClientState.READY, null) - } - - // endregion - - // region notifyReconciling - - @Test - fun `M notify listeners with RECONCILING W notifyReconciling()`() { - // Given - testedChannel.addListener(mockListener) - - // When - testedChannel.notifyReconciling() - - // Then - verify(mockListener).onStateChanged(FlagsClientState.RECONCILING, null) - } - - // endregion - - // region notifyError - - @Test - fun `M notify listeners with ERROR and null W notifyError() {no error provided}`() { - // Given - testedChannel.addListener(mockListener) - - // When - testedChannel.notifyError() - - // Then - verify(mockListener).onStateChanged(FlagsClientState.ERROR, null) - } - - @Test - fun `M notify listeners with ERROR and throwable W notifyError() {error provided}`() { - // Given - testedChannel.addListener(mockListener) - val fakeError = RuntimeException("Test error") - - // When - testedChannel.notifyError(fakeError) - - // Then - verify(mockListener).onStateChanged(FlagsClientState.ERROR, fakeError) - } - - // endregion - - // region addListener / removeListener - - @Test - fun `M notify listener W addListener() and notify`() { - // Given - testedChannel.addListener(mockListener) - - // When - testedChannel.notifyReady() - - // Then - verify(mockListener).onStateChanged(FlagsClientState.READY, null) - } - - @Test - fun `M not notify listener W removeListener() and notify`() { - // Given - testedChannel.addListener(mockListener) - testedChannel.removeListener(mockListener) - - // When - testedChannel.notifyReady() - - // Then - verifyNoInteractions(mockListener) - } - - @Test - fun `M notify all listeners W multiple listeners registered`() { - // Given - val mockListener2 = org.mockito.Mockito.mock(FlagsStateListener::class.java) - testedChannel.addListener(mockListener) - testedChannel.addListener(mockListener2) - - // When - testedChannel.notifyReady() - - // Then - verify(mockListener).onStateChanged(FlagsClientState.READY, null) - verify(mockListener2).onStateChanged(FlagsClientState.READY, null) - } - - @Test - fun `M notify listeners in order W multiple state transitions`() { - // Given - testedChannel.addListener(mockListener) - - // When - testedChannel.notifyNotReady() - testedChannel.notifyReconciling() - testedChannel.notifyReady() - - // Then - val captor = argumentCaptor() - verify(mockListener, org.mockito.kotlin.times(3)).onStateChanged(captor.capture(), org.mockito.kotlin.any()) - - assertThat(captor.allValues).containsExactly( - FlagsClientState.NOT_READY, - FlagsClientState.RECONCILING, - FlagsClientState.READY - ) - } - - // endregion -} diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateManagerTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateManagerTest.kt new file mode 100644 index 0000000000..631c83ce57 --- /dev/null +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateManagerTest.kt @@ -0,0 +1,147 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.flags.internal + +import com.datadog.android.flags.FlagsStateListener +import com.datadog.android.flags.model.FlagsClientState +import com.datadog.android.internal.utils.DDCoreSubscription +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import org.mockito.quality.Strictness +import java.util.concurrent.ExecutorService +import java.util.stream.Stream + +@ExtendWith(MockitoExtension::class) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class FlagsStateManagerTest { + + @Mock + lateinit var mockListener: FlagsStateListener + + @Mock + lateinit var mockExecutorService: ExecutorService + + private lateinit var testedManager: FlagsStateManager + + @BeforeEach + fun `set up`() { + // Mock executor to run tasks synchronously for testing + whenever(mockExecutorService.execute(any())).thenAnswer { invocation -> + val runnable = invocation.getArgument(0) + runnable.run() + } + + testedManager = FlagsStateManager( + subscription = DDCoreSubscription.create(), + executorService = mockExecutorService + ) + } + + // region updateState + + @ParameterizedTest + @MethodSource("allStates") + fun `M notify listeners with state W updateState(state)`(state: FlagsClientState) { + // Given + testedManager.addListener(mockListener) + + // When + testedManager.updateState(state) + + // Then + verify(mockListener).onStateChanged(state) + } + + // endregion + + + // region addListener / removeListener + + @Test + fun `M notify listener W addListener() and notify`() { + // Given + testedManager.addListener(mockListener) + + // When + testedManager.updateState(FlagsClientState.Ready) + + // Then + verify(mockListener).onStateChanged(FlagsClientState.Ready) + } + + @Test + fun `M not notify listener W removeListener() and notify`() { + // Given + testedManager.addListener(mockListener) + testedManager.removeListener(mockListener) + + // When + testedManager.updateState(FlagsClientState.Ready) + + // Then + verifyNoInteractions(mockListener) + } + + @Test + fun `M notify all listeners W multiple listeners registered`() { + // Given + val mockListener2 = mock() + testedManager.addListener(mockListener) + testedManager.addListener(mockListener2) + + // When + testedManager.updateState(FlagsClientState.Ready) + + // Then + verify(mockListener).onStateChanged(FlagsClientState.Ready) + verify(mockListener2).onStateChanged(FlagsClientState.Ready) + } + + @Test + fun `M notify listeners in order W multiple state transitions`() { + // Given + testedManager.addListener(mockListener) + + // When + testedManager.updateState(FlagsClientState.NotReady) + testedManager.updateState(FlagsClientState.Reconciling) + testedManager.updateState(FlagsClientState.Ready) + + // Then + inOrder(mockListener) { + verify(mockListener).onStateChanged(FlagsClientState.NotReady) + verify(mockListener).onStateChanged(FlagsClientState.Reconciling) + verify(mockListener).onStateChanged(FlagsClientState.Ready) + } + } + + // endregion + + companion object { + @JvmStatic + fun allStates(): Stream = Stream.of( + FlagsClientState.NotReady, + FlagsClientState.Ready, + FlagsClientState.Reconciling, + FlagsClientState.Stale, + FlagsClientState.Error(null), + FlagsClientState.Error(RuntimeException("Test error")) + ) + } +} From 44783722128c7ce8907b9570a499ab8588440afd Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 27 Nov 2025 00:43:19 -0700 Subject: [PATCH 12/28] tidy listener api on FlagsClient --- .../com/datadog/android/flags/FlagsClient.kt | 27 ++++-------- .../flags/internal/DatadogFlagsClient.kt | 42 ++----------------- 2 files changed, 13 insertions(+), 56 deletions(-) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt index bc59784cf7..0f8b583b4c 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt @@ -16,7 +16,7 @@ import com.datadog.android.core.InternalSdkCore import com.datadog.android.flags.internal.DatadogFlagsClient import com.datadog.android.flags.internal.DefaultRumEvaluationLogger import com.datadog.android.flags.internal.FlagsFeature -import com.datadog.android.flags.internal.FlagsStateChannel +import com.datadog.android.flags.internal.FlagsStateManager import com.datadog.android.flags.internal.LogWithPolicy import com.datadog.android.flags.internal.NoOpFlagsClient import com.datadog.android.flags.internal.NoOpRumEvaluationLogger @@ -28,7 +28,6 @@ import com.datadog.android.flags.internal.repository.DefaultFlagsRepository import com.datadog.android.flags.internal.repository.NoOpFlagsRepository import com.datadog.android.flags.internal.repository.net.PrecomputeMapper import com.datadog.android.flags.model.EvaluationContext -import com.datadog.android.flags.model.FlagsClientState import com.datadog.android.flags.model.ResolutionDetails import com.datadog.android.internal.utils.DDCoreSubscription import org.json.JSONObject @@ -151,21 +150,12 @@ interface FlagsClient { */ fun resolve(flagKey: String, defaultValue: T): ResolutionDetails - /** - * Gets the current state of this [FlagsClient]. - * - * The state indicates whether the client is ready to evaluate flags, currently loading - * new flag values, or in an error state. - * - * @return The current [FlagsClientState]. - */ - fun getCurrentState(): FlagsClientState - /** * Registers a listener to receive state change notifications. * - * The listener will be notified whenever the client's state changes (e.g., from NOT_READY - * to READY, or from READY to RECONCILING when the evaluation context changes). + * The listener will be notified whenever the client's state changes (e.g., from + * [FlagsClientState.NotReady] to [FlagsClientState.Ready], or from [FlagsClientState.Ready] + * to [FlagsClientState.Reconciling] when the evaluation context changes). * * @param listener The [FlagsStateListener] to register. */ @@ -432,8 +422,9 @@ interface FlagsClient { val precomputeMapper = PrecomputeMapper(featureSdkCore.internalLogger) - val flagStateChannel = FlagsStateChannel( - subscription = DDCoreSubscription.create() + val flagStateManager = FlagsStateManager( + subscription = DDCoreSubscription.create(), + executorService = executorService ) val evaluationsManager = EvaluationsManager( @@ -442,7 +433,7 @@ interface FlagsClient { flagsRepository = flagsRepository, assignmentsReader = assignmentsDownloader, precomputeMapper = precomputeMapper, - flagStateChannel = flagStateChannel + flagStateManager = flagStateManager ) val rumEvaluationLogger = createRumEvaluationLogger(featureSdkCore) @@ -454,7 +445,7 @@ interface FlagsClient { flagsConfiguration = configuration, rumEvaluationLogger = rumEvaluationLogger, processor = flagsFeature.processor, - flagStateChannel = flagStateChannel + flagStateManager = flagStateManager ) } } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt index af7fad2b7a..3b96e6c826 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt @@ -16,11 +16,9 @@ import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.internal.repository.FlagsRepository import com.datadog.android.flags.model.ErrorCode import com.datadog.android.flags.model.EvaluationContext -import com.datadog.android.flags.model.FlagsClientState import com.datadog.android.flags.model.ResolutionDetails import com.datadog.android.flags.model.ResolutionReason import org.json.JSONObject -import java.util.concurrent.atomic.AtomicReference /** * Production implementation of [FlagsClient] that integrates with Datadog's flag evaluation system. @@ -38,7 +36,7 @@ import java.util.concurrent.atomic.AtomicReference * @param flagsConfiguration configuration for the flags feature * @param rumEvaluationLogger responsible for sending flag evaluations to RUM. * @param processor responsible for writing exposure batches to be sent to flags backend. - * @param flagStateChannel channel for managing state change listeners + * @param flagStateManager channel for managing state change listeners */ @Suppress("TooManyFunctions") // All functions are necessary for flag evaluation lifecycle internal class DatadogFlagsClient( @@ -48,38 +46,8 @@ internal class DatadogFlagsClient( private val flagsConfiguration: FlagsConfiguration, private val rumEvaluationLogger: RumEvaluationLogger, private val processor: EventsProcessor, - private val flagStateChannel: FlagsStateChannel + private val flagStateManager: FlagsStateManager ) : FlagsClient { - - // region State Management - - /** - * The current state of this client. - * Thread-safe: uses atomic reference for lock-free reads and updates. - */ - private val currentState = AtomicReference(FlagsClientState.NOT_READY) - - /** - * Updates the client state and notifies all registered listeners. - * - * This method is thread-safe and guarantees that listeners receive state changes in order. - * The notification is delegated to [flagStateChannel] which handles synchronization. - * - * @param newState The new state to transition to. - * @param error Optional error that caused the state change. - */ - internal fun updateState(newState: FlagsClientState, error: Throwable? = null) { - currentState.set(newState) - when (newState) { - FlagsClientState.NOT_READY -> flagStateChannel.notifyNotReady() - FlagsClientState.READY -> flagStateChannel.notifyReady() - FlagsClientState.RECONCILING -> flagStateChannel.notifyReconciling() - FlagsClientState.ERROR -> flagStateChannel.notifyError(error) - } - } - - // endregion - // region FlagsClient /** @@ -184,14 +152,12 @@ internal class DatadogFlagsClient( } } - override fun getCurrentState(): FlagsClientState = currentState.get() - override fun addStateListener(listener: FlagsStateListener) { - flagStateChannel.addListener(listener) + flagStateManager.addListener(listener) } override fun removeStateListener(listener: FlagsStateListener) { - flagStateChannel.removeListener(listener) + flagStateManager.removeListener(listener) } // endregion From a913a5c582b2314dc2da73a49c15bee5985282a7 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 27 Nov 2025 00:43:49 -0700 Subject: [PATCH 13/28] hasFlags and tests --- .../repository/DefaultFlagsRepository.kt | 3 +- .../internal/repository/FlagsRepository.kt | 1 + .../repository/DefaultFlagsRepositoryTest.kt | 115 ++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt index 404aa82cf4..e6feb6ec2c 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt @@ -87,7 +87,8 @@ internal class DefaultFlagsRepository( return atomicState.get()?.context } - @Suppress("ReturnCount") + override fun hasFlags(): Boolean = atomicState.get()?.flags?.isNotEmpty() ?: false + override fun getPrecomputedFlagWithContext(key: String): Pair? { waitForPersistenceLoad() val state = atomicState.get() ?: return null diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/FlagsRepository.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/FlagsRepository.kt index 77dbea779b..09165afe5f 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/FlagsRepository.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/FlagsRepository.kt @@ -16,4 +16,5 @@ internal interface FlagsRepository { fun getEvaluationContext(): EvaluationContext? fun setFlagsAndContext(context: EvaluationContext, flags: Map) fun getPrecomputedFlagWithContext(key: String): Pair? + fun hasFlags(): Boolean } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt index 1f7ae7ad4e..08075594d1 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt @@ -184,4 +184,119 @@ internal class DefaultFlagsRepositoryTest { // Then assertThat(result?.variationValue).isEqualTo(flagValue) } + + // region hasFlags + + @Test + fun `M return expected value W hasFlags() { for various states }`(forge: Forge) { + data class TestCase( + val given: () -> Unit, + val then: Boolean + ) + + val testCases = listOf( + TestCase( + given = { /* no state set */ }, + then = false + ), + TestCase( + given = { + testedRepository.setFlagsAndContext( + EvaluationContext(forge.anAlphabeticalString(), emptyMap()), + emptyMap() + ) + }, + then = false + ), + TestCase( + given = { + testedRepository.setFlagsAndContext( + EvaluationContext(forge.anAlphabeticalString(), emptyMap()), + mapOf( + forge.anAlphabeticalString() to PrecomputedFlag( + variationType = "string", + variationValue = forge.anAlphabeticalString(), + doLog = false, + allocationKey = forge.anAlphabeticalString(), + variationKey = forge.anAlphabeticalString(), + extraLogging = JSONObject(), + reason = "DEFAULT" + ) + ) + ) + }, + then = true + ), + TestCase( + given = { + testedRepository.setFlagsAndContext( + EvaluationContext(forge.anAlphabeticalString(), emptyMap()), + mapOf( + forge.anAlphabeticalString() to PrecomputedFlag( + variationType = "string", + variationValue = forge.anAlphabeticalString(), + doLog = false, + allocationKey = forge.anAlphabeticalString(), + variationKey = forge.anAlphabeticalString(), + extraLogging = JSONObject(), + reason = "DEFAULT" + ), + forge.anAlphabeticalString() to PrecomputedFlag( + variationType = "boolean", + variationValue = "true", + doLog = false, + allocationKey = forge.anAlphabeticalString(), + variationKey = forge.anAlphabeticalString(), + extraLogging = JSONObject(), + reason = "TARGETING_MATCH" + ) + ) + ) + }, + then = true + ) + ) + + testCases.forEach { testCase -> + // Given + testCase.given() + + // When + val result = testedRepository.hasFlags() + + // Then + assertThat(result).isEqualTo(testCase.then) + } + } + + @Test + fun `M not block W hasFlags() { persistence still loading }`() { + // Given + val startTime = System.currentTimeMillis() + doAnswer { + // Never call the callback - simulate slow persistence + null + }.whenever(mockDataStore).value( + key = any(), + version = any(), + callback = any(), + deserializer = any() + ) + val slowRepository = DefaultFlagsRepository( + featureSdkCore = mockFeatureSdkCore, + dataStore = mockDataStore, + instanceName = "slow", + persistenceLoadTimeoutMs = 1000L // Long timeout + ) + + // When + val result = slowRepository.hasFlags() + val elapsedTime = System.currentTimeMillis() - startTime + + // Then + assertThat(result).isFalse + assertThat(elapsedTime).isLessThan(100L) // Should not wait for persistence + } + + // endregion } From 4a54161224d4d51b93c8876f5b7ac85ab66ecc35 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 27 Nov 2025 00:44:09 -0700 Subject: [PATCH 14/28] state listeners --- .../android/flags/internal/NoOpFlagsClient.kt | 7 -- .../android/flags/NoOpFlagsClientTest.kt | 15 +-- .../flags/internal/DatadogFlagsClientTest.kt | 103 ++++++------------ 3 files changed, 34 insertions(+), 91 deletions(-) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt index 4df6067544..dd955d2afb 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt @@ -11,7 +11,6 @@ import com.datadog.android.flags.FlagsClient import com.datadog.android.flags.FlagsStateListener import com.datadog.android.flags.model.ErrorCode import com.datadog.android.flags.model.EvaluationContext -import com.datadog.android.flags.model.FlagsClientState import com.datadog.android.flags.model.ResolutionDetails import org.json.JSONObject @@ -116,12 +115,6 @@ internal class NoOpFlagsClient( return defaultValue } - /** - * Returns [FlagsClientState.ERROR] as this is a no-op client. - * @return Always [FlagsClientState.ERROR]. - */ - override fun getCurrentState(): FlagsClientState = FlagsClientState.ERROR - /** * No-op implementation that ignores listener registration. * @param listener Ignored listener. diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt index eccf1e7cbb..b1036c36f9 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt @@ -11,7 +11,6 @@ import com.datadog.android.flags.internal.LogWithPolicy import com.datadog.android.flags.internal.NoOpFlagsClient import com.datadog.android.flags.model.ErrorCode import com.datadog.android.flags.model.EvaluationContext -import com.datadog.android.flags.model.FlagsClientState import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeExtension @@ -26,6 +25,7 @@ import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.argThat import org.mockito.kotlin.eq +import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.quality.Strictness @@ -430,19 +430,10 @@ internal class NoOpFlagsClientTest { // region State Management - @Test - fun `M return ERROR state W getCurrentState()`() { - // When - val result = testedClient.getCurrentState() - - // Then - assertThat(result).isEqualTo(FlagsClientState.ERROR) - } - @Test fun `M do nothing W addStateListener()`() { // Given - val mockListener = org.mockito.Mockito.mock(FlagsStateListener::class.java) + val mockListener = mock() // When testedClient.addStateListener(mockListener) @@ -455,7 +446,7 @@ internal class NoOpFlagsClientTest { @Test fun `M do nothing W removeStateListener()`() { // Given - val mockListener = org.mockito.Mockito.mock(FlagsStateListener::class.java) + val mockListener = mock() // When testedClient.removeStateListener(mockListener) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt index 887bf4e147..86e4ee0d5d 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt @@ -17,7 +17,6 @@ import com.datadog.android.flags.internal.model.VariationType import com.datadog.android.flags.internal.repository.FlagsRepository import com.datadog.android.flags.model.ErrorCode import com.datadog.android.flags.model.EvaluationContext -import com.datadog.android.flags.model.FlagsClientState import com.datadog.android.flags.model.ResolutionReason import com.datadog.android.flags.utils.forge.ForgeConfigurator import com.datadog.android.internal.utils.DDCoreSubscription @@ -43,6 +42,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import org.mockito.quality.Strictness +import java.util.concurrent.ExecutorService @Extensions( ExtendWith(MockitoExtension::class), @@ -70,6 +70,9 @@ internal class DatadogFlagsClientTest { @Mock lateinit var mockRumEvaluationLogger: RumEvaluationLogger + @Mock + lateinit var mockExecutorService: ExecutorService + private lateinit var testedClient: DatadogFlagsClient @StringForgery @@ -83,6 +86,12 @@ internal class DatadogFlagsClientTest { whenever(mockFeatureSdkCore.internalLogger) doReturn mockInternalLogger whenever(mockFeatureSdkCore.getFeature(RUM_FEATURE_NAME)) doReturn mock() + // Mock executor to run tasks synchronously for testing + whenever(mockExecutorService.execute(any())).thenAnswer { invocation -> + val runnable = invocation.getArgument(0) + runnable.run() + } + // Mock evaluation context as ready by default // Tests that need to test "not ready" state should override this val defaultContext = EvaluationContext( @@ -101,7 +110,10 @@ internal class DatadogFlagsClientTest { ), rumEvaluationLogger = mockRumEvaluationLogger, processor = mockProcessor, - flagStateChannel = FlagsStateChannel(DDCoreSubscription.create()) + flagStateManager = FlagsStateManager( + subscription = DDCoreSubscription.create(), + executorService = mockExecutorService + ) ) } @@ -642,7 +654,11 @@ internal class DatadogFlagsClientTest { flagsRepository = customRepository, flagsConfiguration = forge.getForgery(), rumEvaluationLogger = mockRumEvaluationLogger, - processor = mockProcessor + processor = mockProcessor, + flagStateManager = FlagsStateManager( + subscription = DDCoreSubscription.create(), + executorService = mockExecutorService + ) ) // When @@ -941,7 +957,11 @@ internal class DatadogFlagsClientTest { rumIntegrationEnabled = false ), rumEvaluationLogger = mockRumEvaluationLogger, - processor = mockProcessor + processor = mockProcessor, + flagStateManager = FlagsStateManager( + subscription = DDCoreSubscription.create(), + executorService = mockExecutorService + ) ) // When @@ -1045,7 +1065,11 @@ internal class DatadogFlagsClientTest { rumIntegrationEnabled = false ), rumEvaluationLogger = mockRumEvaluationLogger, - processor = mockProcessor + processor = mockProcessor, + flagStateManager = FlagsStateManager( + subscription = DDCoreSubscription.create(), + executorService = mockExecutorService + ) ) whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn (fakeFlag to fakeEvaluationContext) @@ -1299,15 +1323,6 @@ internal class DatadogFlagsClientTest { // region State Management - @Test - fun `M return NOT_READY W getCurrentState() {initial state}`() { - // When - val result = testedClient.getCurrentState() - - // Then - assertThat(result).isEqualTo(FlagsClientState.NOT_READY) - } - @Test fun `M add listener W addStateListener()`() { // Given @@ -1335,64 +1350,8 @@ internal class DatadogFlagsClientTest { verifyNoInteractions(mockListener) } - @Test - fun `M notify listener W updateState() called`() { - // Given - val mockListener = mock(FlagsStateListener::class.java) - testedClient.addStateListener(mockListener) - - // When - testedClient.updateState(FlagsClientState.READY, null) - - // Then - verify(mockListener).onStateChanged(FlagsClientState.READY, null) - assertThat(testedClient.getCurrentState()).isEqualTo(FlagsClientState.READY) - } - - @Test - fun `M notify listener with error W updateState(ERROR) called`() { - // Given - val mockListener = mock(FlagsStateListener::class.java) - val fakeError = RuntimeException("Test error") - testedClient.addStateListener(mockListener) - - // When - testedClient.updateState(FlagsClientState.ERROR, fakeError) - - // Then - verify(mockListener).onStateChanged(FlagsClientState.ERROR, fakeError) - assertThat(testedClient.getCurrentState()).isEqualTo(FlagsClientState.ERROR) - } - - @Test - fun `M notify all listeners W updateState() with multiple listeners`() { - // Given - val mockListener1 = mock(FlagsStateListener::class.java) - val mockListener2 = mock(FlagsStateListener::class.java) - testedClient.addStateListener(mockListener1) - testedClient.addStateListener(mockListener2) - - // When - testedClient.updateState(FlagsClientState.RECONCILING, null) - - // Then - verify(mockListener1).onStateChanged(FlagsClientState.RECONCILING, null) - verify(mockListener2).onStateChanged(FlagsClientState.RECONCILING, null) - } - - @Test - fun `M not notify removed listener W removeStateListener() then updateState()`() { - // Given - val mockListener = mock(FlagsStateListener::class.java) - testedClient.addStateListener(mockListener) - testedClient.removeStateListener(mockListener) - - // When - testedClient.updateState(FlagsClientState.READY, null) - - // Then - verifyNoInteractions(mockListener) - } + // Note: updateState() tests removed - this is now an internal method called only by + // EvaluationsManager. State notification testing is covered in FlagsStateManagerTest. // endregion } From 949a69382571055f38c99ef8aa831bee2f03ed32 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 27 Nov 2025 01:11:24 -0700 Subject: [PATCH 15/28] emit current state --- features/dd-sdk-android-flags/api/apiSurface | 15 +++--- .../api/dd-sdk-android-flags.api | 44 ++++++++++++----- .../flags/internal/FlagsStateManager.kt | 24 +++++++-- .../internal/evaluation/EvaluationsManager.kt | 40 ++++++++------- .../repository/DefaultFlagsRepository.kt | 1 + .../flags/internal/DatadogFlagsClientTest.kt | 12 +++-- .../flags/internal/FlagsStateManagerTest.kt | 43 +++++++++++----- .../evaluation/EvaluationsManagerTest.kt | 49 ++++++++++++++----- .../repository/DefaultFlagsRepositoryTest.kt | 5 +- 9 files changed, 156 insertions(+), 77 deletions(-) diff --git a/features/dd-sdk-android-flags/api/apiSurface b/features/dd-sdk-android-flags/api/apiSurface index 57eaeba8bf..86385c69bb 100644 --- a/features/dd-sdk-android-flags/api/apiSurface +++ b/features/dd-sdk-android-flags/api/apiSurface @@ -8,7 +8,6 @@ interface com.datadog.android.flags.FlagsClient fun resolveIntValue(String, Int): Int fun resolveStructureValue(String, org.json.JSONObject): org.json.JSONObject fun resolve(String, T): com.datadog.android.flags.model.ResolutionDetails - fun getCurrentState(): com.datadog.android.flags.model.FlagsClientState fun addStateListener(FlagsStateListener) fun removeStateListener(FlagsStateListener) class Builder @@ -26,7 +25,7 @@ data class com.datadog.android.flags.FlagsConfiguration fun build(): FlagsConfiguration companion object interface com.datadog.android.flags.FlagsStateListener - fun onStateChanged(com.datadog.android.flags.model.FlagsClientState, Throwable? = null) + fun onStateChanged(com.datadog.android.flags.model.FlagsClientState) enum com.datadog.android.flags.model.ErrorCode - PROVIDER_NOT_READY - FLAG_NOT_FOUND @@ -34,11 +33,13 @@ enum com.datadog.android.flags.model.ErrorCode - TYPE_MISMATCH data class com.datadog.android.flags.model.EvaluationContext constructor(String, Map = emptyMap()) -enum com.datadog.android.flags.model.FlagsClientState - - NOT_READY - - READY - - RECONCILING - - ERROR +sealed class com.datadog.android.flags.model.FlagsClientState + object NotReady : FlagsClientState + object Ready : FlagsClientState + object Reconciling : FlagsClientState + object Stale : FlagsClientState + data class Error : FlagsClientState + constructor(Throwable? = null) data class com.datadog.android.flags.model.ResolutionDetails constructor(T, String? = null, ResolutionReason? = null, ErrorCode? = null, String? = null, Map = emptyMap()) enum com.datadog.android.flags.model.ResolutionReason diff --git a/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api b/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api index 96d9ca19fb..c9d10367db 100644 --- a/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api +++ b/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api @@ -12,7 +12,6 @@ public abstract interface class com/datadog/android/flags/FlagsClient { public static fun get ()Lcom/datadog/android/flags/FlagsClient; public static fun get (Ljava/lang/String;)Lcom/datadog/android/flags/FlagsClient; public static fun get (Ljava/lang/String;Lcom/datadog/android/api/SdkCore;)Lcom/datadog/android/flags/FlagsClient; - public abstract fun getCurrentState ()Lcom/datadog/android/flags/model/FlagsClientState; public abstract fun removeStateListener (Lcom/datadog/android/flags/FlagsStateListener;)V public abstract fun resolve (Ljava/lang/String;Ljava/lang/Object;)Lcom/datadog/android/flags/model/ResolutionDetails; public abstract fun resolveBooleanValue (Ljava/lang/String;Z)Z @@ -59,11 +58,7 @@ public final class com/datadog/android/flags/FlagsConfiguration$Companion { } public abstract interface class com/datadog/android/flags/FlagsStateListener { - public abstract fun onStateChanged (Lcom/datadog/android/flags/model/FlagsClientState;Ljava/lang/Throwable;)V -} - -public final class com/datadog/android/flags/FlagsStateListener$DefaultImpls { - public static synthetic fun onStateChanged$default (Lcom/datadog/android/flags/FlagsStateListener;Lcom/datadog/android/flags/model/FlagsClientState;Ljava/lang/Throwable;ILjava/lang/Object;)V + public abstract fun onStateChanged (Lcom/datadog/android/flags/model/FlagsClientState;)V } 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 public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/flags/model/ExposureEvent$Subject; } -public final class com/datadog/android/flags/model/FlagsClientState : java/lang/Enum { - public static final field ERROR Lcom/datadog/android/flags/model/FlagsClientState; - public static final field NOT_READY Lcom/datadog/android/flags/model/FlagsClientState; - public static final field READY Lcom/datadog/android/flags/model/FlagsClientState; - public static final field RECONCILING Lcom/datadog/android/flags/model/FlagsClientState; - public static fun valueOf (Ljava/lang/String;)Lcom/datadog/android/flags/model/FlagsClientState; - public static fun values ()[Lcom/datadog/android/flags/model/FlagsClientState; +public abstract class com/datadog/android/flags/model/FlagsClientState { +} + +public final class com/datadog/android/flags/model/FlagsClientState$Error : com/datadog/android/flags/model/FlagsClientState { + public fun ()V + public fun (Ljava/lang/Throwable;)V + public synthetic fun (Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/Throwable; + public final fun copy (Ljava/lang/Throwable;)Lcom/datadog/android/flags/model/FlagsClientState$Error; + 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; + public fun equals (Ljava/lang/Object;)Z + public final fun getError ()Ljava/lang/Throwable; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/flags/model/FlagsClientState$NotReady : com/datadog/android/flags/model/FlagsClientState { + public static final field INSTANCE Lcom/datadog/android/flags/model/FlagsClientState$NotReady; +} + +public final class com/datadog/android/flags/model/FlagsClientState$Ready : com/datadog/android/flags/model/FlagsClientState { + public static final field INSTANCE Lcom/datadog/android/flags/model/FlagsClientState$Ready; +} + +public final class com/datadog/android/flags/model/FlagsClientState$Reconciling : com/datadog/android/flags/model/FlagsClientState { + public static final field INSTANCE Lcom/datadog/android/flags/model/FlagsClientState$Reconciling; +} + +public final class com/datadog/android/flags/model/FlagsClientState$Stale : com/datadog/android/flags/model/FlagsClientState { + public static final field INSTANCE Lcom/datadog/android/flags/model/FlagsClientState$Stale; } public final class com/datadog/android/flags/model/ResolutionDetails { diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt index 2448cc5e52..92c38991da 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt @@ -18,7 +18,8 @@ import java.util.concurrent.ExecutorService * methods are thread-safe and guarantee ordered delivery to listeners by using a * single-threaded executor service. * - * State updates trigger listener notifications asynchronously on the executor service. + * The current state is stored and emitted to new listeners immediately upon registration, + * ensuring every listener receives the current state. * * @param subscription the underlying subscription for managing listeners * @param executorService single-threaded executor for ordered state notification delivery @@ -27,16 +28,24 @@ internal class FlagsStateManager( private val subscription: DDCoreSubscription, private val executorService: ExecutorService ) { + /** + * The current state of the client. + * Thread-safe: uses volatile for visibility across threads. + */ + @Volatile + private var currentState: FlagsClientState = FlagsClientState.NotReady + /** * Updates the state and notifies all listeners. * - * This method asynchronously notifies all registered listeners on the executor service, - * ensuring ordered delivery. + * This method stores the new state and asynchronously notifies all registered listeners + * on the executor service, ensuring ordered delivery. * * @param newState The new state to transition to. */ internal fun updateState(newState: FlagsClientState) { executorService.execute { + currentState = newState subscription.notifyListeners { onStateChanged(newState) } @@ -46,10 +55,19 @@ internal class FlagsStateManager( /** * Registers a listener to receive state change notifications. * + * The listener will immediately receive the current state, then be notified + * of all future state changes. + * * @param listener The listener to add. */ fun addListener(listener: FlagsStateListener) { subscription.addListener(listener) + + // Emit current state to new listener + val state = currentState + executorService.execute { + listener.onStateChanged(state) + } } /** diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt index e655e64043..fbf0b85ffb 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManager.kt @@ -8,11 +8,12 @@ package com.datadog.android.flags.internal.evaluation import com.datadog.android.api.InternalLogger import com.datadog.android.core.internal.utils.executeSafe -import com.datadog.android.flags.internal.FlagsStateChannel +import com.datadog.android.flags.internal.FlagsStateManager import com.datadog.android.flags.internal.net.PrecomputedAssignmentsReader import com.datadog.android.flags.internal.repository.FlagsRepository import com.datadog.android.flags.internal.repository.net.PrecomputeMapper import com.datadog.android.flags.model.EvaluationContext +import com.datadog.android.flags.model.FlagsClientState import java.util.concurrent.ExecutorService /** @@ -27,7 +28,7 @@ import java.util.concurrent.ExecutorService * @param flagsRepository local storage for flag data and evaluation context * @param assignmentsReader handles reading assignments for the context. * @param precomputeMapper transforms network responses into internal flag format - * @param flagStateChannel channel for notifying state change listeners + * @param flagStateManager channel for notifying state change listeners */ internal class EvaluationsManager( private val executorService: ExecutorService, @@ -35,7 +36,7 @@ internal class EvaluationsManager( private val flagsRepository: FlagsRepository, private val assignmentsReader: PrecomputedAssignmentsReader, private val precomputeMapper: PrecomputeMapper, - private val flagStateChannel: FlagsStateChannel + private val flagStateManager: FlagsStateManager ) { /** * Processes a new evaluation context by fetching flags and storing atomically. @@ -51,7 +52,7 @@ internal class EvaluationsManager( * a valid targeting key. */ fun updateEvaluationsForContext(context: EvaluationContext) { - flagStateChannel.notifyReconciling() + flagStateManager.updateState(FlagsClientState.Reconciling) executorService.executeSafe( operationName = FETCH_AND_STORE_OPERATION_NAME, @@ -63,29 +64,30 @@ internal class EvaluationsManager( { "Processing evaluation context: ${context.targetingKey}" } ) + val hadFlags = flagsRepository.hasFlags() val response = assignmentsReader.readPrecomputedFlags(context) - val flagsMap = if (response != null) { - precomputeMapper.map(response) + if (response != null) { + val flagsMap = precomputeMapper.map(response) + flagsRepository.setFlagsAndContext(context, flagsMap) + internalLogger.log( + InternalLogger.Level.DEBUG, + InternalLogger.Target.MAINTAINER, + { "Successfully processed context ${context.targetingKey} with ${flagsMap.size} flags" } + ) + + flagStateManager.updateState(FlagsClientState.Ready) } else { internalLogger.log( InternalLogger.Level.WARN, InternalLogger.Target.USER, { NETWORK_REQUEST_FAILED_MESSAGE } ) - emptyMap() - } - - flagsRepository.setFlagsAndContext(context, flagsMap) - internalLogger.log( - InternalLogger.Level.DEBUG, - InternalLogger.Target.MAINTAINER, - { "Successfully processed context ${context.targetingKey} with ${flagsMap.size} flags" } - ) - if (response != null) { - flagStateChannel.notifyReady() - } else { - flagStateChannel.notifyError() + if (hadFlags) { + flagStateManager.updateState(FlagsClientState.Stale) + } else { + flagStateManager.updateState(FlagsClientState.Error(Throwable(NETWORK_REQUEST_FAILED_MESSAGE))) + } } } } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt index e6feb6ec2c..376e28f67e 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepository.kt @@ -89,6 +89,7 @@ internal class DefaultFlagsRepository( override fun hasFlags(): Boolean = atomicState.get()?.flags?.isNotEmpty() ?: false + @Suppress("ReturnCount") override fun getPrecomputedFlagWithContext(key: String): Pair? { waitForPersistenceLoad() val state = atomicState.get() ?: return null diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt index 86e4ee0d5d..eb929395c1 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt @@ -17,6 +17,7 @@ import com.datadog.android.flags.internal.model.VariationType import com.datadog.android.flags.internal.repository.FlagsRepository import com.datadog.android.flags.model.ErrorCode import com.datadog.android.flags.model.EvaluationContext +import com.datadog.android.flags.model.FlagsClientState import com.datadog.android.flags.model.ResolutionReason import com.datadog.android.flags.utils.forge.ForgeConfigurator import com.datadog.android.internal.utils.DDCoreSubscription @@ -1332,8 +1333,8 @@ internal class DatadogFlagsClientTest { testedClient.addStateListener(mockListener) // Then - // No exception should be thrown - verifyNoInteractions(mockListener) + // Listener should immediately receive current state (NotReady) + verify(mockListener).onStateChanged(FlagsClientState.NotReady) } @Test @@ -1341,13 +1342,14 @@ internal class DatadogFlagsClientTest { // Given val mockListener = mock(FlagsStateListener::class.java) testedClient.addStateListener(mockListener) + // Verify initial state was emitted + verify(mockListener).onStateChanged(FlagsClientState.NotReady) // When testedClient.removeStateListener(mockListener) - // Then - // No exception should be thrown - verifyNoInteractions(mockListener) + // Then - no exception should be thrown + org.mockito.kotlin.verifyNoMoreInteractions(mockListener) } // Note: updateState() tests removed - this is now an internal method called only by diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateManagerTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateManagerTest.kt index 631c83ce57..6e4f97371b 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateManagerTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateManagerTest.kt @@ -20,8 +20,8 @@ import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.any import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock +import org.mockito.kotlin.times import org.mockito.kotlin.verify -import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import org.mockito.quality.Strictness import java.util.concurrent.ExecutorService @@ -65,12 +65,19 @@ internal class FlagsStateManagerTest { testedManager.updateState(state) // Then - verify(mockListener).onStateChanged(state) + if (state == FlagsClientState.NotReady) { + // Special case: NotReady is both initial state and transition state + verify(mockListener, times(2)).onStateChanged(FlagsClientState.NotReady) + } else { + inOrder(mockListener) { + verify(mockListener).onStateChanged(FlagsClientState.NotReady) // Initial state on add + verify(mockListener).onStateChanged(state) // State change + } + } } // endregion - // region addListener / removeListener @Test @@ -82,20 +89,26 @@ internal class FlagsStateManagerTest { testedManager.updateState(FlagsClientState.Ready) // Then - verify(mockListener).onStateChanged(FlagsClientState.Ready) + inOrder(mockListener) { + verify(mockListener).onStateChanged(FlagsClientState.NotReady) // Current state on add + verify(mockListener).onStateChanged(FlagsClientState.Ready) // State update + } } @Test - fun `M not notify listener W removeListener() and notify`() { + fun `M not notify listener after removal W removeListener() and notify`() { // Given testedManager.addListener(mockListener) + // Verify initial state was emitted + verify(mockListener).onStateChanged(FlagsClientState.NotReady) + testedManager.removeListener(mockListener) // When testedManager.updateState(FlagsClientState.Ready) - // Then - verifyNoInteractions(mockListener) + // Then - no further notifications after removal + org.mockito.kotlin.verifyNoMoreInteractions(mockListener) } @Test @@ -109,8 +122,13 @@ internal class FlagsStateManagerTest { testedManager.updateState(FlagsClientState.Ready) // Then - verify(mockListener).onStateChanged(FlagsClientState.Ready) - verify(mockListener2).onStateChanged(FlagsClientState.Ready) + // Both listeners get current state on add, then Ready notification + inOrder(mockListener, mockListener2) { + verify(mockListener).onStateChanged(FlagsClientState.NotReady) + verify(mockListener2).onStateChanged(FlagsClientState.NotReady) + verify(mockListener).onStateChanged(FlagsClientState.Ready) + verify(mockListener2).onStateChanged(FlagsClientState.Ready) + } } @Test @@ -119,15 +137,14 @@ internal class FlagsStateManagerTest { testedManager.addListener(mockListener) // When - testedManager.updateState(FlagsClientState.NotReady) testedManager.updateState(FlagsClientState.Reconciling) testedManager.updateState(FlagsClientState.Ready) // Then inOrder(mockListener) { - verify(mockListener).onStateChanged(FlagsClientState.NotReady) - verify(mockListener).onStateChanged(FlagsClientState.Reconciling) - verify(mockListener).onStateChanged(FlagsClientState.Ready) + verify(mockListener).onStateChanged(FlagsClientState.NotReady) // Initial on add + verify(mockListener).onStateChanged(FlagsClientState.Reconciling) // Transition + verify(mockListener).onStateChanged(FlagsClientState.Ready) // Transition } } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt index 9c9050aed3..4d5b2ac0d4 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/evaluation/EvaluationsManagerTest.kt @@ -7,12 +7,13 @@ package com.datadog.android.flags.internal.evaluation import com.datadog.android.api.InternalLogger -import com.datadog.android.flags.internal.FlagsStateChannel +import com.datadog.android.flags.internal.FlagsStateManager import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.internal.net.PrecomputedAssignmentsReader import com.datadog.android.flags.internal.repository.FlagsRepository import com.datadog.android.flags.internal.repository.net.PrecomputeMapper import com.datadog.android.flags.model.EvaluationContext +import com.datadog.android.flags.model.FlagsClientState import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeExtension import okhttp3.mockwebserver.MockWebServer @@ -29,6 +30,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -55,7 +57,7 @@ internal class EvaluationsManagerTest { lateinit var mockPrecomputeMapper: PrecomputeMapper @Mock - lateinit var mockFlagsStateChannel: FlagsStateChannel + lateinit var mockFlagsStateManager: FlagsStateManager @StringForgery lateinit var fakeTargetingKey: String @@ -80,7 +82,7 @@ internal class EvaluationsManagerTest { flagsRepository = mockFlagsRepository, assignmentsReader = mockAssignmentsDownloader, precomputeMapper = mockPrecomputeMapper, - flagStateChannel = mockFlagsStateChannel + flagStateManager = mockFlagsStateManager ) // Mock executor to run tasks synchronously for testing @@ -163,8 +165,8 @@ internal class EvaluationsManagerTest { evaluationsManager.updateEvaluationsForContext(context) // Then - // When response is null, flags are not updated - only debug log is emitted - verify(mockInternalLogger, times(2)).log( + // When response is null, only 1 debug log (processing start) and 1 warn log (failure) + verify(mockInternalLogger, times(1)).log( eq(InternalLogger.Level.DEBUG), eq(InternalLogger.Target.MAINTAINER), any<() -> String>(), @@ -212,7 +214,7 @@ internal class EvaluationsManagerTest { // Then val logCaptor = argumentCaptor<() -> String>() - verify(mockInternalLogger, times(2)).log( + verify(mockInternalLogger, times(1)).log( eq(InternalLogger.Level.DEBUG), eq(InternalLogger.Target.MAINTAINER), logCaptor.capture(), @@ -240,25 +242,46 @@ internal class EvaluationsManagerTest { evaluationsManager.updateEvaluationsForContext(publicContext) // Then - val inOrder = org.mockito.kotlin.inOrder(mockFlagsStateChannel) - inOrder.verify(mockFlagsStateChannel).notifyReconciling() - inOrder.verify(mockFlagsStateChannel).notifyReady() + inOrder(mockFlagsStateManager) { + verify(mockFlagsStateManager).updateState(FlagsClientState.Reconciling) + verify(mockFlagsStateManager).updateState(FlagsClientState.Ready) + } } @Test - fun `M notify RECONCILING then ERROR W updateEvaluationsForContext() { network failure }`() { + fun `M notify RECONCILING then ERROR W updateEvaluationsForContext() { network failure, no previous flags }`() { // Given val publicContext = EvaluationContext(fakeTargetingKey, emptyMap()) + whenever(mockFlagsRepository.hasFlags()).thenReturn(false) whenever(mockAssignmentsDownloader.readPrecomputedFlags(publicContext)).thenReturn(null) // When evaluationsManager.updateEvaluationsForContext(publicContext) // Then - val inOrder = org.mockito.kotlin.inOrder(mockFlagsStateChannel) - inOrder.verify(mockFlagsStateChannel).notifyReconciling() - inOrder.verify(mockFlagsStateChannel).notifyError() + inOrder(mockFlagsStateManager) { + verify(mockFlagsStateManager).updateState(FlagsClientState.Reconciling) + verify(mockFlagsStateManager).updateState(org.mockito.kotlin.argThat { this is FlagsClientState.Error }) + } + } + + @Test + fun `M notify RECONCILING then STALE W updateEvaluationsForContext() { network failure, has previous flags }`() { + // Given + val publicContext = EvaluationContext(fakeTargetingKey, emptyMap()) + + whenever(mockFlagsRepository.hasFlags()).thenReturn(true) + whenever(mockAssignmentsDownloader.readPrecomputedFlags(publicContext)).thenReturn(null) + + // When + evaluationsManager.updateEvaluationsForContext(publicContext) + + // Then + inOrder(mockFlagsStateManager) { + verify(mockFlagsStateManager).updateState(FlagsClientState.Reconciling) + verify(mockFlagsStateManager).updateState(FlagsClientState.Stale) + } } // endregion diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt index 08075594d1..667fe590a6 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt @@ -189,10 +189,7 @@ internal class DefaultFlagsRepositoryTest { @Test fun `M return expected value W hasFlags() { for various states }`(forge: Forge) { - data class TestCase( - val given: () -> Unit, - val then: Boolean - ) + data class TestCase(val given: () -> Unit, val then: Boolean) val testCases = listOf( TestCase( From be53823315f12a4b67cef41e6b082ef0e23577a1 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 27 Nov 2025 01:19:06 -0700 Subject: [PATCH 16/28] fix race --- .../datadog/android/flags/internal/FlagsStateManager.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt index 92c38991da..295cfc666c 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt @@ -56,17 +56,17 @@ internal class FlagsStateManager( * Registers a listener to receive state change notifications. * * The listener will immediately receive the current state, then be notified - * of all future state changes. + * of all future state changes. The current state is read atomically on the + * same executor where all state updates occur, ensuring correct ordering. * * @param listener The listener to add. */ fun addListener(listener: FlagsStateListener) { subscription.addListener(listener) - // Emit current state to new listener - val state = currentState + // Emit current state to new listener - read inside executor for atomicity executorService.execute { - listener.onStateChanged(state) + listener.onStateChanged(currentState) } } From d2b729d62c0f0e50ef9d7dc917118ec6f8565420 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 27 Nov 2025 13:24:11 -0700 Subject: [PATCH 17/28] separate executors, abstract state interaction out of FlagsClient --- features/dd-sdk-android-flags/api/apiSurface | 8 +- .../api/dd-sdk-android-flags.api | 10 ++- .../dd-sdk-android-flags/build.gradle.kts | 1 + .../com/datadog/android/flags/FlagsClient.kt | 50 +++++++----- .../datadog/android/flags/StateObservable.kt | 79 +++++++++++++++++++ .../flags/internal/DatadogFlagsClient.kt | 13 +-- .../flags/internal/FlagsStateManager.kt | 56 ++++++++----- .../android/flags/internal/NoOpFlagsClient.kt | 18 +---- .../flags/internal/NoOpStateObservable.kt | 37 +++++++++ .../com/datadog/android/flags/FlagsTest.kt | 4 +- .../android/flags/NoOpFlagsClientTest.kt | 18 ++++- .../flags/internal/DatadogFlagsClientTest.kt | 30 ++++--- .../flags/internal/FlagsStateManagerTest.kt | 9 ++- local_ci.sh | 1 - 14 files changed, 245 insertions(+), 89 deletions(-) create mode 100644 features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/StateObservable.kt create mode 100644 features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpStateObservable.kt diff --git a/features/dd-sdk-android-flags/api/apiSurface b/features/dd-sdk-android-flags/api/apiSurface index 86385c69bb..fd7b7e8703 100644 --- a/features/dd-sdk-android-flags/api/apiSurface +++ b/features/dd-sdk-android-flags/api/apiSurface @@ -8,8 +8,7 @@ interface com.datadog.android.flags.FlagsClient fun resolveIntValue(String, Int): Int fun resolveStructureValue(String, org.json.JSONObject): org.json.JSONObject fun resolve(String, T): com.datadog.android.flags.model.ResolutionDetails - fun addStateListener(FlagsStateListener) - fun removeStateListener(FlagsStateListener) + val state: StateObservable class Builder constructor(String = DEFAULT_CLIENT_NAME, com.datadog.android.api.SdkCore = Datadog.getInstance()) fun build(): FlagsClient @@ -26,6 +25,11 @@ data class com.datadog.android.flags.FlagsConfiguration companion object interface com.datadog.android.flags.FlagsStateListener fun onStateChanged(com.datadog.android.flags.model.FlagsClientState) +interface com.datadog.android.flags.StateObservable + val flow: kotlinx.coroutines.flow.StateFlow + fun getCurrentState(): com.datadog.android.flags.model.FlagsClientState + fun addListener(FlagsStateListener) + fun removeListener(FlagsStateListener) enum com.datadog.android.flags.model.ErrorCode - PROVIDER_NOT_READY - FLAG_NOT_FOUND diff --git a/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api b/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api index c9d10367db..95be8d1334 100644 --- a/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api +++ b/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api @@ -8,11 +8,10 @@ public final class com/datadog/android/flags/Flags { public abstract interface class com/datadog/android/flags/FlagsClient { public static final field Companion Lcom/datadog/android/flags/FlagsClient$Companion; - public abstract fun addStateListener (Lcom/datadog/android/flags/FlagsStateListener;)V public static fun get ()Lcom/datadog/android/flags/FlagsClient; public static fun get (Ljava/lang/String;)Lcom/datadog/android/flags/FlagsClient; public static fun get (Ljava/lang/String;Lcom/datadog/android/api/SdkCore;)Lcom/datadog/android/flags/FlagsClient; - public abstract fun removeStateListener (Lcom/datadog/android/flags/FlagsStateListener;)V + public abstract fun getState ()Lcom/datadog/android/flags/StateObservable; public abstract fun resolve (Ljava/lang/String;Ljava/lang/Object;)Lcom/datadog/android/flags/model/ResolutionDetails; public abstract fun resolveBooleanValue (Ljava/lang/String;Z)Z public abstract fun resolveDoubleValue (Ljava/lang/String;D)D @@ -61,6 +60,13 @@ public abstract interface class com/datadog/android/flags/FlagsStateListener { public abstract fun onStateChanged (Lcom/datadog/android/flags/model/FlagsClientState;)V } +public abstract interface class com/datadog/android/flags/StateObservable { + public abstract fun addListener (Lcom/datadog/android/flags/FlagsStateListener;)V + public abstract fun getCurrentState ()Lcom/datadog/android/flags/model/FlagsClientState; + public abstract fun getFlow ()Lkotlinx/coroutines/flow/StateFlow; + public abstract fun removeListener (Lcom/datadog/android/flags/FlagsStateListener;)V +} + public final class com/datadog/android/flags/model/ErrorCode : java/lang/Enum { public static final field FLAG_NOT_FOUND Lcom/datadog/android/flags/model/ErrorCode; public static final field PARSE_ERROR Lcom/datadog/android/flags/model/ErrorCode; diff --git a/features/dd-sdk-android-flags/build.gradle.kts b/features/dd-sdk-android-flags/build.gradle.kts index 0af2d5c3c0..202c2703da 100644 --- a/features/dd-sdk-android-flags/build.gradle.kts +++ b/features/dd-sdk-android-flags/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { implementation(libs.gson) implementation(libs.kotlin) + implementation(libs.coroutinesCore) implementation(libs.okHttp) implementation(libs.androidXAnnotation) implementation(libs.androidXCollection) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt index 0f8b583b4c..04e365017e 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt @@ -151,24 +151,26 @@ interface FlagsClient { fun resolve(flagKey: String, defaultValue: T): ResolutionDetails /** - * Registers a listener to receive state change notifications. + * Observable interface for tracking client state changes. * - * The listener will be notified whenever the client's state changes (e.g., from - * [FlagsClientState.NotReady] to [FlagsClientState.Ready], or from [FlagsClientState.Ready] - * to [FlagsClientState.Reconciling] when the evaluation context changes). + * Provides three ways to observe state: + * - Synchronous: [StateObservable.getCurrentState] for immediate queries (Java-friendly) + * - Reactive: [StateObservable.flow] for coroutine-based updates (Kotlin) + * - Callback: [StateObservable.addListener] for traditional observers (Java-friendly) * - * @param listener The [FlagsStateListener] to register. - */ - fun addStateListener(listener: FlagsStateListener) - - /** - * Unregisters a previously registered state listener. + * Example: + * ```kotlin + * // Synchronous + * val current = client.state.getCurrentState() * - * After removal, the listener will no longer receive state change notifications. + * // Reactive Flow + * client.state.flow.collect { state -> /* ... */ } * - * @param listener The [FlagsStateListener] to unregister. + * // Callback + * client.state.addListener(listener) + * ``` */ - fun removeStateListener(listener: FlagsStateListener) + val state: StateObservable /** * Builder for creating [FlagsClient] instances with custom configuration. @@ -360,7 +362,8 @@ interface FlagsClient { // region Internal - internal const val FLAGS_CLIENT_EXECUTOR_NAME = "flags-client-executor" + internal const val FLAGS_NETWORK_EXECUTOR_NAME = "flags-network" + internal const val FLAGS_STATE_NOTIFICATION_EXECUTOR_NAME = "flags-state-notifications" @Suppress("LongMethod") internal fun createInternal( @@ -369,8 +372,16 @@ interface FlagsClient { flagsFeature: FlagsFeature, name: String ): FlagsClient { - val executorService = featureSdkCore.createSingleThreadExecutorService( - executorContext = FLAGS_CLIENT_EXECUTOR_NAME + // Separate executors for network I/O vs state notifications + // Network executor handles slow operations (fetching flags, JSON parsing) + val networkExecutorService = featureSdkCore.createSingleThreadExecutorService( + executorContext = FLAGS_NETWORK_EXECUTOR_NAME + ) + + // State notification executor handles fast operations (listener callbacks) + // Separate from network to ensure state updates are not blocked by I/O + val stateNotificationExecutorService = featureSdkCore.createSingleThreadExecutorService( + executorContext = FLAGS_STATE_NOTIFICATION_EXECUTOR_NAME ) val datadogContext = (featureSdkCore as InternalSdkCore).getDatadogContext() @@ -423,12 +434,13 @@ interface FlagsClient { val precomputeMapper = PrecomputeMapper(featureSdkCore.internalLogger) val flagStateManager = FlagsStateManager( - subscription = DDCoreSubscription.create(), - executorService = executorService + DDCoreSubscription.create(), + stateNotificationExecutorService, + featureSdkCore.internalLogger ) val evaluationsManager = EvaluationsManager( - executorService = executorService, + executorService = networkExecutorService, internalLogger = featureSdkCore.internalLogger, flagsRepository = flagsRepository, assignmentsReader = assignmentsDownloader, diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/StateObservable.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/StateObservable.kt new file mode 100644 index 0000000000..04fe9c13c6 --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/StateObservable.kt @@ -0,0 +1,79 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.flags + +import com.datadog.android.flags.model.FlagsClientState +import kotlinx.coroutines.flow.StateFlow + +/** + * Observable interface for tracking [FlagsClient] state changes. + * + * This interface provides three ways to observe state: + * 1. **Synchronous getter**: [getCurrentState] for immediate state queries (Java-friendly) + * 2. **Reactive Flow**: [flow] for coroutine-based reactive updates (Kotlin) + * 3. **Callback pattern**: [addListener]/[removeListener] for traditional observers (Java-friendly) + * + * ## Usage Examples + * + * ```kotlin + * // Synchronous getter (Java-friendly, no Flow dependency) + * val current = client.state.getCurrentState() + * if (current is FlagsClientState.Ready) { + * // Proceed + * } + * + * // Reactive Flow (Kotlin coroutines) + * client.state.flow.value // Current value + * client.state.flow.collect { state -> // Collect updates + * // Handle state change + * } + * + * // Callback pattern (Java-friendly) + * client.state.addListener(object : FlagsStateListener { + * override fun onStateChanged(newState: FlagsClientState) { + * // Handle state change + * } + * }) + * ``` + */ +interface StateObservable { + /** + * Reactive Flow of state changes. + * + * This [StateFlow] emits the current state immediately to new collectors, + * then emits all subsequent state changes. Suitable for Kotlin coroutines users. + */ + val flow: StateFlow + + /** + * Returns the current state synchronously. + * + * This method is safe to call from any thread and does not require coroutines. + * Suitable for Java users or quick state checks. + * + * @return The current [FlagsClientState]. + */ + fun getCurrentState(): FlagsClientState + + /** + * Registers a listener to receive state change notifications. + * + * The listener will immediately receive the current state upon registration, + * then be notified of all future state changes. Suitable for Java users or + * when Flow is not available. + * + * @param listener The [FlagsStateListener] to register. + */ + fun addListener(listener: FlagsStateListener) + + /** + * Unregisters a previously registered state listener. + * + * @param listener The [FlagsStateListener] to unregister. + */ + fun removeListener(listener: FlagsStateListener) +} diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt index 3b96e6c826..281367cb93 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/DatadogFlagsClient.kt @@ -10,7 +10,7 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.flags.FlagsClient import com.datadog.android.flags.FlagsConfiguration -import com.datadog.android.flags.FlagsStateListener +import com.datadog.android.flags.StateObservable import com.datadog.android.flags.internal.evaluation.EvaluationsManager import com.datadog.android.flags.internal.model.PrecomputedFlag import com.datadog.android.flags.internal.repository.FlagsRepository @@ -48,6 +48,9 @@ internal class DatadogFlagsClient( private val processor: EventsProcessor, private val flagStateManager: FlagsStateManager ) : FlagsClient { + + override val state: StateObservable = flagStateManager + // region FlagsClient /** @@ -152,14 +155,6 @@ internal class DatadogFlagsClient( } } - override fun addStateListener(listener: FlagsStateListener) { - flagStateManager.addListener(listener) - } - - override fun removeStateListener(listener: FlagsStateListener) { - flagStateManager.removeListener(listener) - } - // endregion // region Private Implementation diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt index 295cfc666c..6c48cafadb 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt @@ -6,9 +6,15 @@ package com.datadog.android.flags.internal +import com.datadog.android.api.InternalLogger +import com.datadog.android.core.internal.utils.executeSafe import com.datadog.android.flags.FlagsStateListener +import com.datadog.android.flags.StateObservable import com.datadog.android.flags.model.FlagsClientState import com.datadog.android.internal.utils.DDCoreSubscription +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import java.util.concurrent.ExecutorService /** @@ -23,11 +29,19 @@ import java.util.concurrent.ExecutorService * * @param subscription the underlying subscription for managing listeners * @param executorService single-threaded executor for ordered state notification delivery + * @param internalLogger logger for error and debug messages */ internal class FlagsStateManager( private val subscription: DDCoreSubscription, - private val executorService: ExecutorService -) { + private val executorService: ExecutorService, + private val internalLogger: InternalLogger +) : StateObservable { + /** + * The current state of the client as a mutable flow. + * Updates are synchronized through the executor service to ensure ordered delivery. + */ + private val _stateFlow = MutableStateFlow(FlagsClientState.NotReady) + /** * The current state of the client. * Thread-safe: uses volatile for visibility across threads. @@ -44,38 +58,40 @@ internal class FlagsStateManager( * @param newState The new state to transition to. */ internal fun updateState(newState: FlagsClientState) { - executorService.execute { + executorService.executeSafe( + operationName = UPDATE_STATE_OPERATION_NAME, + internalLogger = internalLogger + ) { currentState = newState + _stateFlow.value = newState subscription.notifyListeners { onStateChanged(newState) } } } - /** - * Registers a listener to receive state change notifications. - * - * The listener will immediately receive the current state, then be notified - * of all future state changes. The current state is read atomically on the - * same executor where all state updates occur, ensuring correct ordering. - * - * @param listener The listener to add. - */ - fun addListener(listener: FlagsStateListener) { + override val flow: StateFlow = _stateFlow.asStateFlow() + + override fun getCurrentState(): FlagsClientState = currentState + + override fun addListener(listener: FlagsStateListener) { subscription.addListener(listener) // Emit current state to new listener - read inside executor for atomicity - executorService.execute { + executorService.executeSafe( + operationName = NOTIFY_NEW_LISTENER_OPERATION_NAME, + internalLogger = internalLogger + ) { listener.onStateChanged(currentState) } } - /** - * Unregisters a previously registered listener. - * - * @param listener The listener to remove. - */ - fun removeListener(listener: FlagsStateListener) { + override fun removeListener(listener: FlagsStateListener) { subscription.removeListener(listener) } + + companion object { + private const val UPDATE_STATE_OPERATION_NAME = "Update flags client state" + private const val NOTIFY_NEW_LISTENER_OPERATION_NAME = "Notify new listener of current flags state" + } } diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt index dd955d2afb..21f0053245 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt @@ -8,7 +8,7 @@ package com.datadog.android.flags.internal import com.datadog.android.api.InternalLogger import com.datadog.android.flags.FlagsClient -import com.datadog.android.flags.FlagsStateListener +import com.datadog.android.flags.StateObservable import com.datadog.android.flags.model.ErrorCode import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.flags.model.ResolutionDetails @@ -33,6 +33,8 @@ internal class NoOpFlagsClient( private val logWithPolicy: LogWithPolicy ) : FlagsClient { + override val state: StateObservable = NoOpStateObservable() + /** * No-op implementation that ignores context updates and logs a warning. * @param context Ignored evaluation context. @@ -115,20 +117,6 @@ internal class NoOpFlagsClient( return defaultValue } - /** - * No-op implementation that ignores listener registration. - * @param listener Ignored listener. - */ - override fun addStateListener(listener: FlagsStateListener) { - } - - /** - * No-op implementation that ignores listener removal. - * @param listener Ignored listener. - */ - override fun removeStateListener(listener: FlagsStateListener) { - } - /** * Logs an operation call on this NoOpFlagsClient using the policy-aware logging function. * This ensures visibility in both debug builds (MAINTAINER) and production (USER, if verbosity allows). diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpStateObservable.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpStateObservable.kt new file mode 100644 index 0000000000..f2dfb61141 --- /dev/null +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpStateObservable.kt @@ -0,0 +1,37 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ + +package com.datadog.android.flags.internal + +import com.datadog.android.flags.FlagsStateListener +import com.datadog.android.flags.StateObservable +import com.datadog.android.flags.model.FlagsClientState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * No-operation implementation of [StateObservable]. + * + * This implementation always returns [FlagsClientState.Error] state and ignores + * all listener registration attempts. + */ +internal class NoOpStateObservable : StateObservable { + + private val _stateFlow = MutableStateFlow(FlagsClientState.Error(null)) + + override val flow: StateFlow = _stateFlow.asStateFlow() + + override fun getCurrentState(): FlagsClientState = FlagsClientState.Error(null) + + override fun addListener(listener: FlagsStateListener) { + // No-op - silently ignores listener registration + } + + override fun removeListener(listener: FlagsStateListener) { + // No-op - silently ignores listener removal + } +} diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsTest.kt index 05d6a0d608..9160ebc0be 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsTest.kt @@ -12,7 +12,7 @@ import com.datadog.android.api.context.DatadogContext import com.datadog.android.api.feature.Feature.Companion.FLAGS_FEATURE_NAME import com.datadog.android.api.feature.Feature.Companion.RUM_FEATURE_NAME import com.datadog.android.core.InternalSdkCore -import com.datadog.android.flags.FlagsClient.Companion.FLAGS_CLIENT_EXECUTOR_NAME +// Executor name imports removed - now using any() matcher for flexibility import com.datadog.android.flags.internal.FlagsFeature import com.datadog.android.flags.utils.forge.ForgeConfigurator import fr.xgouchet.elmyr.annotation.StringForgery @@ -59,7 +59,7 @@ internal class FlagsTest { @BeforeEach fun `set up`() { whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger - whenever(mockSdkCore.createSingleThreadExecutorService(FLAGS_CLIENT_EXECUTOR_NAME)) doReturn + whenever(mockSdkCore.createSingleThreadExecutorService(org.mockito.kotlin.any())) doReturn mockExecutorService whenever(mockDatadogContext.clientToken) doReturn fakeClientToken diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt index b1036c36f9..0dec102441 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt @@ -11,6 +11,7 @@ import com.datadog.android.flags.internal.LogWithPolicy import com.datadog.android.flags.internal.NoOpFlagsClient import com.datadog.android.flags.model.ErrorCode import com.datadog.android.flags.model.EvaluationContext +import com.datadog.android.flags.model.FlagsClientState import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeExtension @@ -431,12 +432,21 @@ internal class NoOpFlagsClientTest { // region State Management @Test - fun `M do nothing W addStateListener()`() { + fun `M return error state W state_getCurrentState()`() { + // When + val state = testedClient.state.getCurrentState() + + // Then + assertThat(state).isInstanceOf(FlagsClientState.Error::class.java) + } + + @Test + fun `M do nothing W state_addListener()`() { // Given val mockListener = mock() // When - testedClient.addStateListener(mockListener) + testedClient.state.addListener(mockListener) // Then // No exception should be thrown, method should be no-op @@ -444,12 +454,12 @@ internal class NoOpFlagsClientTest { } @Test - fun `M do nothing W removeStateListener()`() { + fun `M do nothing W state_removeListener()`() { // Given val mockListener = mock() // When - testedClient.removeStateListener(mockListener) + testedClient.state.removeListener(mockListener) // Then // No exception should be thrown, method should be no-op diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt index eb929395c1..293f430cc4 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt @@ -112,8 +112,9 @@ internal class DatadogFlagsClientTest { rumEvaluationLogger = mockRumEvaluationLogger, processor = mockProcessor, flagStateManager = FlagsStateManager( - subscription = DDCoreSubscription.create(), - executorService = mockExecutorService + DDCoreSubscription.create(), + mockExecutorService, + mockInternalLogger ) ) } @@ -657,8 +658,9 @@ internal class DatadogFlagsClientTest { rumEvaluationLogger = mockRumEvaluationLogger, processor = mockProcessor, flagStateManager = FlagsStateManager( - subscription = DDCoreSubscription.create(), - executorService = mockExecutorService + DDCoreSubscription.create(), + mockExecutorService, + mockInternalLogger ) ) @@ -960,8 +962,9 @@ internal class DatadogFlagsClientTest { rumEvaluationLogger = mockRumEvaluationLogger, processor = mockProcessor, flagStateManager = FlagsStateManager( - subscription = DDCoreSubscription.create(), - executorService = mockExecutorService + DDCoreSubscription.create(), + mockExecutorService, + mockInternalLogger ) ) @@ -1068,8 +1071,9 @@ internal class DatadogFlagsClientTest { rumEvaluationLogger = mockRumEvaluationLogger, processor = mockProcessor, flagStateManager = FlagsStateManager( - subscription = DDCoreSubscription.create(), - executorService = mockExecutorService + DDCoreSubscription.create(), + mockExecutorService, + mockInternalLogger ) ) whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn @@ -1325,12 +1329,12 @@ internal class DatadogFlagsClientTest { // region State Management @Test - fun `M add listener W addStateListener()`() { + fun `M add listener W state_addListener()`() { // Given val mockListener = mock(FlagsStateListener::class.java) // When - testedClient.addStateListener(mockListener) + testedClient.state.addListener(mockListener) // Then // Listener should immediately receive current state (NotReady) @@ -1338,15 +1342,15 @@ internal class DatadogFlagsClientTest { } @Test - fun `M remove listener W removeStateListener()`() { + fun `M remove listener W state_removeListener()`() { // Given val mockListener = mock(FlagsStateListener::class.java) - testedClient.addStateListener(mockListener) + testedClient.state.addListener(mockListener) // Verify initial state was emitted verify(mockListener).onStateChanged(FlagsClientState.NotReady) // When - testedClient.removeStateListener(mockListener) + testedClient.state.removeListener(mockListener) // Then - no exception should be thrown org.mockito.kotlin.verifyNoMoreInteractions(mockListener) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateManagerTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateManagerTest.kt index 6e4f97371b..4f70ba2b78 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateManagerTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateManagerTest.kt @@ -6,6 +6,7 @@ package com.datadog.android.flags.internal +import com.datadog.android.api.InternalLogger import com.datadog.android.flags.FlagsStateListener import com.datadog.android.flags.model.FlagsClientState import com.datadog.android.internal.utils.DDCoreSubscription @@ -37,6 +38,9 @@ internal class FlagsStateManagerTest { @Mock lateinit var mockExecutorService: ExecutorService + @Mock + lateinit var mockInternalLogger: InternalLogger + private lateinit var testedManager: FlagsStateManager @BeforeEach @@ -48,8 +52,9 @@ internal class FlagsStateManagerTest { } testedManager = FlagsStateManager( - subscription = DDCoreSubscription.create(), - executorService = mockExecutorService + DDCoreSubscription.create(), + mockExecutorService, + mockInternalLogger ) } diff --git a/local_ci.sh b/local_ci.sh index b4b3852f30..de4c660806 100755 --- a/local_ci.sh +++ b/local_ci.sh @@ -94,7 +94,6 @@ if [[ $CLEANUP == 1 ]]; then rm -rf dd-sdk-android-internal/build/ rm -rf dd-sdk-android-core/build/ rm -rf features/dd-sdk-android-flags/build/ - rm -rf features/dd-sdk-android-flags-openfeature/build/ rm -rf features/dd-sdk-android-logs/build/ rm -rf features/dd-sdk-android-ndk/build/ rm -rf features/dd-sdk-android-rum/build/ From 6d79a8ba97e4ac623d0778e2a83595a6cab0d249 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 27 Nov 2025 13:42:30 -0700 Subject: [PATCH 18/28] lint imports --- .../src/test/kotlin/com/datadog/android/flags/FlagsTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsTest.kt index 9160ebc0be..08e96095d4 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsTest.kt @@ -12,7 +12,6 @@ import com.datadog.android.api.context.DatadogContext import com.datadog.android.api.feature.Feature.Companion.FLAGS_FEATURE_NAME import com.datadog.android.api.feature.Feature.Companion.RUM_FEATURE_NAME import com.datadog.android.core.InternalSdkCore -// Executor name imports removed - now using any() matcher for flexibility import com.datadog.android.flags.internal.FlagsFeature import com.datadog.android.flags.utils.forge.ForgeConfigurator import fr.xgouchet.elmyr.annotation.StringForgery From f341b577548fbad20d7aca851640cbd0cc69b742 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 27 Nov 2025 14:21:45 -0700 Subject: [PATCH 19/28] safe calls and sync on state --- detekt_custom_safe_calls.yml | 3 ++ .../flags/internal/FlagsStateManager.kt | 18 +++++--- .../flags/internal/DatadogFlagsClientTest.kt | 43 ++++++------------- 3 files changed, 29 insertions(+), 35 deletions(-) diff --git a/detekt_custom_safe_calls.yml b/detekt_custom_safe_calls.yml index 69ed988d91..b58b9ffaec 100644 --- a/detekt_custom_safe_calls.yml +++ b/detekt_custom_safe_calls.yml @@ -1066,6 +1066,7 @@ datadog: - "kotlin.IllegalArgumentException(kotlin.String?)" - "kotlin.IllegalStateException(kotlin.String?)" - "kotlin.Throwable.constructor()" + - "kotlin.Throwable.constructor(kotlin.String?)" - "kotlin.Throwable.fillInStackTrace()" - "kotlin.Throwable.stackTraceToString()" - "kotlin.UnsupportedOperationException()" @@ -1083,6 +1084,8 @@ datadog: - "kotlinx.coroutines.flow.FlowCollector.emit(kotlin.Any?)" - "kotlinx.coroutines.flow.FlowCollector(kotlin.coroutines.SuspendFunction1)" - "kotlinx.coroutines.flow.flow(kotlin.coroutines.SuspendFunction1)" + - "kotlinx.coroutines.flow.MutableStateFlow(com.datadog.android.flags.model.FlagsClientState)" + - "kotlinx.coroutines.flow.MutableStateFlow.asStateFlow()" # endregion # region Kronos - "com.lyft.kronos.AndroidClockFactory.createKronosClock(android.content.Context, com.lyft.kronos.SyncListener?, kotlin.collections.List, kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long)" diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt index 6c48cafadb..869dcd5e35 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt @@ -38,13 +38,15 @@ internal class FlagsStateManager( ) : StateObservable { /** * The current state of the client as a mutable flow. - * Updates are synchronized through the executor service to ensure ordered delivery. + * + * Updates are coordinated through the executor service to ensure ordered delivery. + * MutableStateFlow itself is thread-safe. */ private val _stateFlow = MutableStateFlow(FlagsClientState.NotReady) /** * The current state of the client. - * Thread-safe: uses volatile for visibility across threads. + * Thread-safe: synchronized to ensure atomicity with flow updates. */ @Volatile private var currentState: FlagsClientState = FlagsClientState.NotReady @@ -62,8 +64,10 @@ internal class FlagsStateManager( operationName = UPDATE_STATE_OPERATION_NAME, internalLogger = internalLogger ) { - currentState = newState - _stateFlow.value = newState + synchronized(this) { + currentState = newState + _stateFlow.value = newState + } subscription.notifyListeners { onStateChanged(newState) } @@ -72,17 +76,19 @@ internal class FlagsStateManager( override val flow: StateFlow = _stateFlow.asStateFlow() + @Synchronized override fun getCurrentState(): FlagsClientState = currentState override fun addListener(listener: FlagsStateListener) { subscription.addListener(listener) - // Emit current state to new listener - read inside executor for atomicity + // Emit current state to new listener executorService.executeSafe( operationName = NOTIFY_NEW_LISTENER_OPERATION_NAME, internalLogger = internalLogger ) { - listener.onStateChanged(currentState) + val state = synchronized(this) { currentState } + listener.onStateChanged(state) } } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt index 293f430cc4..ba2ea83c3c 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt @@ -74,6 +74,9 @@ internal class DatadogFlagsClientTest { @Mock lateinit var mockExecutorService: ExecutorService + @Mock + lateinit var mockFlagsStateManager: FlagsStateManager + private lateinit var testedClient: DatadogFlagsClient @StringForgery @@ -111,11 +114,7 @@ internal class DatadogFlagsClientTest { ), rumEvaluationLogger = mockRumEvaluationLogger, processor = mockProcessor, - flagStateManager = FlagsStateManager( - DDCoreSubscription.create(), - mockExecutorService, - mockInternalLogger - ) + flagStateManager = mockFlagsStateManager ) } @@ -657,11 +656,7 @@ internal class DatadogFlagsClientTest { flagsConfiguration = forge.getForgery(), rumEvaluationLogger = mockRumEvaluationLogger, processor = mockProcessor, - flagStateManager = FlagsStateManager( - DDCoreSubscription.create(), - mockExecutorService, - mockInternalLogger - ) + flagStateManager = mockFlagsStateManager ) // When @@ -961,11 +956,7 @@ internal class DatadogFlagsClientTest { ), rumEvaluationLogger = mockRumEvaluationLogger, processor = mockProcessor, - flagStateManager = FlagsStateManager( - DDCoreSubscription.create(), - mockExecutorService, - mockInternalLogger - ) + flagStateManager = mockFlagsStateManager ) // When @@ -1070,11 +1061,7 @@ internal class DatadogFlagsClientTest { ), rumEvaluationLogger = mockRumEvaluationLogger, processor = mockProcessor, - flagStateManager = FlagsStateManager( - DDCoreSubscription.create(), - mockExecutorService, - mockInternalLogger - ) + flagStateManager = mockFlagsStateManager ) whenever(mockFlagsRepository.getPrecomputedFlagWithContext(fakeFlagKey)) doReturn (fakeFlag to fakeEvaluationContext) @@ -1329,7 +1316,7 @@ internal class DatadogFlagsClientTest { // region State Management @Test - fun `M add listener W state_addListener()`() { + fun `M delegate to state manager W state_addListener()`() { // Given val mockListener = mock(FlagsStateListener::class.java) @@ -1337,23 +1324,21 @@ internal class DatadogFlagsClientTest { testedClient.state.addListener(mockListener) // Then - // Listener should immediately receive current state (NotReady) - verify(mockListener).onStateChanged(FlagsClientState.NotReady) + // Verify delegation to state manager + verify(mockFlagsStateManager).addListener(mockListener) } @Test - fun `M remove listener W state_removeListener()`() { + fun `M delegate to state manager W state_removeListener()`() { // Given val mockListener = mock(FlagsStateListener::class.java) - testedClient.state.addListener(mockListener) - // Verify initial state was emitted - verify(mockListener).onStateChanged(FlagsClientState.NotReady) // When testedClient.state.removeListener(mockListener) - // Then - no exception should be thrown - org.mockito.kotlin.verifyNoMoreInteractions(mockListener) + // Then + // Verify delegation to state manager + verify(mockFlagsStateManager).removeListener(mockListener) } // Note: updateState() tests removed - this is now an internal method called only by From 1cb6beb09f56690eb9f6d10c6e74d3e7224fd005 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 27 Nov 2025 14:36:07 -0700 Subject: [PATCH 20/28] lint --- .../datadog/android/flags/internal/DatadogFlagsClientTest.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt index ba2ea83c3c..3600c44f33 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt @@ -17,10 +17,8 @@ import com.datadog.android.flags.internal.model.VariationType import com.datadog.android.flags.internal.repository.FlagsRepository import com.datadog.android.flags.model.ErrorCode import com.datadog.android.flags.model.EvaluationContext -import com.datadog.android.flags.model.FlagsClientState import com.datadog.android.flags.model.ResolutionReason import com.datadog.android.flags.utils.forge.ForgeConfigurator -import com.datadog.android.internal.utils.DDCoreSubscription import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration From 9332ebf2905fb1dff5599868abf28228645427c4 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 27 Nov 2025 14:43:34 -0700 Subject: [PATCH 21/28] not using atomic reference for flags client state --- detekt_custom_safe_calls.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/detekt_custom_safe_calls.yml b/detekt_custom_safe_calls.yml index b58b9ffaec..5dde086f3f 100644 --- a/detekt_custom_safe_calls.yml +++ b/detekt_custom_safe_calls.yml @@ -462,7 +462,6 @@ datadog: - "java.util.concurrent.atomic.AtomicReference.set(com.datadog.android.rum.internal.domain.RumContext?)" - "java.util.concurrent.atomic.AtomicReference.set(com.datadog.android.trace.api.tracer.DatadogTracer?)" - "java.util.concurrent.atomic.AtomicReference.set(com.datadog.android.flags.internal.repository.DefaultFlagsRepository.FlagsState?)" - - "java.util.concurrent.atomic.AtomicReference.set(com.datadog.android.flags.model.FlagsClientState?)" - "java.util.concurrent.atomic.AtomicReference.set(com.datadog.android.flags.model.ProviderContext?)" - "java.util.concurrent.atomic.AtomicReference.set(com.datadog.trace.bootstrap.instrumentation.api.AgentTracer.TracerAPI?)" - "java.util.concurrent.atomic.AtomicReference.set(com.datadog.trace.core.CoreTracer?)" From 81a95bde0c75182e417d5ee9253119fcc00e9ed2 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 27 Nov 2025 14:50:01 -0700 Subject: [PATCH 22/28] comments and noop client is always ready --- .../com/datadog/android/flags/FlagsClient.kt | 5 --- .../android/flags/internal/NoOpFlagsClient.kt | 13 ++++++- .../flags/internal/NoOpStateObservable.kt | 37 ------------------- .../android/flags/NoOpFlagsClientTest.kt | 5 ++- 4 files changed, 15 insertions(+), 45 deletions(-) delete mode 100644 features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpStateObservable.kt diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt index 04e365017e..e90b8306a6 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt @@ -372,14 +372,9 @@ interface FlagsClient { flagsFeature: FlagsFeature, name: String ): FlagsClient { - // Separate executors for network I/O vs state notifications - // Network executor handles slow operations (fetching flags, JSON parsing) val networkExecutorService = featureSdkCore.createSingleThreadExecutorService( executorContext = FLAGS_NETWORK_EXECUTOR_NAME ) - - // State notification executor handles fast operations (listener callbacks) - // Separate from network to ensure state updates are not blocked by I/O val stateNotificationExecutorService = featureSdkCore.createSingleThreadExecutorService( executorContext = FLAGS_STATE_NOTIFICATION_EXECUTOR_NAME ) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt index 21f0053245..e348771ff2 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt @@ -8,10 +8,15 @@ package com.datadog.android.flags.internal import com.datadog.android.api.InternalLogger import com.datadog.android.flags.FlagsClient +import com.datadog.android.flags.FlagsStateListener import com.datadog.android.flags.StateObservable import com.datadog.android.flags.model.ErrorCode import com.datadog.android.flags.model.EvaluationContext +import com.datadog.android.flags.model.FlagsClientState import com.datadog.android.flags.model.ResolutionDetails +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import org.json.JSONObject /** @@ -33,7 +38,13 @@ internal class NoOpFlagsClient( private val logWithPolicy: LogWithPolicy ) : FlagsClient { - override val state: StateObservable = NoOpStateObservable() + override val state: StateObservable = object : StateObservable { + private val _stateFlow = MutableStateFlow(FlagsClientState.Ready) + override val flow: StateFlow = _stateFlow.asStateFlow() + override fun getCurrentState(): FlagsClientState = FlagsClientState.Ready + override fun addListener(listener: FlagsStateListener) { /* no-op */ } + override fun removeListener(listener: FlagsStateListener) { /* no-op */ } + } /** * No-op implementation that ignores context updates and logs a warning. diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpStateObservable.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpStateObservable.kt deleted file mode 100644 index f2dfb61141..0000000000 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpStateObservable.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. - * This product includes software developed at Datadog (https://www.datadoghq.com/). - * Copyright 2016-Present Datadog, Inc. - */ - -package com.datadog.android.flags.internal - -import com.datadog.android.flags.FlagsStateListener -import com.datadog.android.flags.StateObservable -import com.datadog.android.flags.model.FlagsClientState -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -/** - * No-operation implementation of [StateObservable]. - * - * This implementation always returns [FlagsClientState.Error] state and ignores - * all listener registration attempts. - */ -internal class NoOpStateObservable : StateObservable { - - private val _stateFlow = MutableStateFlow(FlagsClientState.Error(null)) - - override val flow: StateFlow = _stateFlow.asStateFlow() - - override fun getCurrentState(): FlagsClientState = FlagsClientState.Error(null) - - override fun addListener(listener: FlagsStateListener) { - // No-op - silently ignores listener registration - } - - override fun removeListener(listener: FlagsStateListener) { - // No-op - silently ignores listener removal - } -} diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt index 0dec102441..103cce3a32 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt @@ -432,12 +432,13 @@ internal class NoOpFlagsClientTest { // region State Management @Test - fun `M return error state W state_getCurrentState()`() { + fun `M return ready state W state_getCurrentState()`() { // When val state = testedClient.state.getCurrentState() // Then - assertThat(state).isInstanceOf(FlagsClientState.Error::class.java) + // NoOp client is always "ready" to return default values + assertThat(state).isEqualTo(FlagsClientState.Ready) } @Test From acda00f4622ecdd141c59dcee5421532841a3cfc Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 27 Nov 2025 15:14:19 -0700 Subject: [PATCH 23/28] api import coroutines --- features/dd-sdk-android-flags/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/dd-sdk-android-flags/build.gradle.kts b/features/dd-sdk-android-flags/build.gradle.kts index 202c2703da..ca2132c211 100644 --- a/features/dd-sdk-android-flags/build.gradle.kts +++ b/features/dd-sdk-android-flags/build.gradle.kts @@ -52,7 +52,7 @@ dependencies { implementation(libs.gson) implementation(libs.kotlin) - implementation(libs.coroutinesCore) + api(libs.coroutinesCore) implementation(libs.okHttp) implementation(libs.androidXAnnotation) implementation(libs.androidXCollection) From 5a288d848b0828bc1cf3125d80db23ce85ddd6d8 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Thu, 27 Nov 2025 15:26:14 -0700 Subject: [PATCH 24/28] add transitive deps --- features/dd-sdk-android-flags/transitiveDependencies | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/features/dd-sdk-android-flags/transitiveDependencies b/features/dd-sdk-android-flags/transitiveDependencies index c4394247cc..9ee447485b 100644 --- a/features/dd-sdk-android-flags/transitiveDependencies +++ b/features/dd-sdk-android-flags/transitiveDependencies @@ -8,7 +8,8 @@ com.squareup.okio:okio-jvm:3.6.0 : 351 Kb org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10 : 959 b org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10 : 965 b org.jetbrains.kotlin:kotlin-stdlib:2.0.21 : 1706 Kb +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.4.2 : 1635 Kb org.jetbrains:annotations:13.0 : 17 Kb -Total transitive dependencies size : 3 Mb +Total transitive dependencies size : 5 Mb From 7fdd2253bc5f04f79837e2245331af65311b8902 Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 28 Nov 2025 10:10:56 -0700 Subject: [PATCH 25/28] remove coroutines and Flow --- features/dd-sdk-android-flags/api/apiSurface | 1 - .../api/dd-sdk-android-flags.api | 1 - .../dd-sdk-android-flags/build.gradle.kts | 1 - .../datadog/android/flags/StateObservable.kt | 32 ++++--------------- .../flags/internal/FlagsStateManager.kt | 30 ++++++----------- .../android/flags/internal/NoOpFlagsClient.kt | 7 +--- .../android/flags/NoOpFlagsClientTest.kt | 5 ++- .../transitiveDependencies | 3 +- 8 files changed, 20 insertions(+), 60 deletions(-) diff --git a/features/dd-sdk-android-flags/api/apiSurface b/features/dd-sdk-android-flags/api/apiSurface index fd7b7e8703..e9f6e35603 100644 --- a/features/dd-sdk-android-flags/api/apiSurface +++ b/features/dd-sdk-android-flags/api/apiSurface @@ -26,7 +26,6 @@ data class com.datadog.android.flags.FlagsConfiguration interface com.datadog.android.flags.FlagsStateListener fun onStateChanged(com.datadog.android.flags.model.FlagsClientState) interface com.datadog.android.flags.StateObservable - val flow: kotlinx.coroutines.flow.StateFlow fun getCurrentState(): com.datadog.android.flags.model.FlagsClientState fun addListener(FlagsStateListener) fun removeListener(FlagsStateListener) diff --git a/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api b/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api index 95be8d1334..8bc02440ed 100644 --- a/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api +++ b/features/dd-sdk-android-flags/api/dd-sdk-android-flags.api @@ -63,7 +63,6 @@ public abstract interface class com/datadog/android/flags/FlagsStateListener { public abstract interface class com/datadog/android/flags/StateObservable { public abstract fun addListener (Lcom/datadog/android/flags/FlagsStateListener;)V public abstract fun getCurrentState ()Lcom/datadog/android/flags/model/FlagsClientState; - public abstract fun getFlow ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun removeListener (Lcom/datadog/android/flags/FlagsStateListener;)V } diff --git a/features/dd-sdk-android-flags/build.gradle.kts b/features/dd-sdk-android-flags/build.gradle.kts index ca2132c211..0af2d5c3c0 100644 --- a/features/dd-sdk-android-flags/build.gradle.kts +++ b/features/dd-sdk-android-flags/build.gradle.kts @@ -52,7 +52,6 @@ dependencies { implementation(libs.gson) implementation(libs.kotlin) - api(libs.coroutinesCore) implementation(libs.okHttp) implementation(libs.androidXAnnotation) implementation(libs.androidXCollection) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/StateObservable.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/StateObservable.kt index 04fe9c13c6..efd5035c21 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/StateObservable.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/StateObservable.kt @@ -7,32 +7,24 @@ package com.datadog.android.flags import com.datadog.android.flags.model.FlagsClientState -import kotlinx.coroutines.flow.StateFlow /** * Observable interface for tracking [FlagsClient] state changes. * - * This interface provides three ways to observe state: - * 1. **Synchronous getter**: [getCurrentState] for immediate state queries (Java-friendly) - * 2. **Reactive Flow**: [flow] for coroutine-based reactive updates (Kotlin) - * 3. **Callback pattern**: [addListener]/[removeListener] for traditional observers (Java-friendly) + * This interface provides two ways to observe state: + * 1. **Synchronous getter**: [getCurrentState] for immediate state queries + * 2. **Callback pattern**: [addListener]/[removeListener] for reactive observers * * ## Usage Examples * * ```kotlin - * // Synchronous getter (Java-friendly, no Flow dependency) + * // Synchronous getter * val current = client.state.getCurrentState() * if (current is FlagsClientState.Ready) { * // Proceed * } * - * // Reactive Flow (Kotlin coroutines) - * client.state.flow.value // Current value - * client.state.flow.collect { state -> // Collect updates - * // Handle state change - * } - * - * // Callback pattern (Java-friendly) + * // Callback pattern * client.state.addListener(object : FlagsStateListener { * override fun onStateChanged(newState: FlagsClientState) { * // Handle state change @@ -41,19 +33,10 @@ import kotlinx.coroutines.flow.StateFlow * ``` */ interface StateObservable { - /** - * Reactive Flow of state changes. - * - * This [StateFlow] emits the current state immediately to new collectors, - * then emits all subsequent state changes. Suitable for Kotlin coroutines users. - */ - val flow: StateFlow - /** * Returns the current state synchronously. * - * This method is safe to call from any thread and does not require coroutines. - * Suitable for Java users or quick state checks. + * This method is safe to call from any thread. * * @return The current [FlagsClientState]. */ @@ -63,8 +46,7 @@ interface StateObservable { * Registers a listener to receive state change notifications. * * The listener will immediately receive the current state upon registration, - * then be notified of all future state changes. Suitable for Java users or - * when Flow is not available. + * then be notified of all future state changes. * * @param listener The [FlagsStateListener] to register. */ diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt index 869dcd5e35..4b08d9ff1a 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt @@ -12,9 +12,6 @@ import com.datadog.android.flags.FlagsStateListener import com.datadog.android.flags.StateObservable import com.datadog.android.flags.model.FlagsClientState import com.datadog.android.internal.utils.DDCoreSubscription -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import java.util.concurrent.ExecutorService /** @@ -36,21 +33,20 @@ internal class FlagsStateManager( private val executorService: ExecutorService, private val internalLogger: InternalLogger ) : StateObservable { - /** - * The current state of the client as a mutable flow. - * - * Updates are coordinated through the executor service to ensure ordered delivery. - * MutableStateFlow itself is thread-safe. - */ - private val _stateFlow = MutableStateFlow(FlagsClientState.NotReady) - /** * The current state of the client. - * Thread-safe: synchronized to ensure atomicity with flow updates. + * Thread-safe: uses volatile for visibility across threads. */ @Volatile private var currentState: FlagsClientState = FlagsClientState.NotReady + /** + * Returns the current state synchronously. + * + * @return The current [FlagsClientState]. + */ + override fun getCurrentState(): FlagsClientState = currentState + /** * Updates the state and notifies all listeners. * @@ -64,21 +60,13 @@ internal class FlagsStateManager( operationName = UPDATE_STATE_OPERATION_NAME, internalLogger = internalLogger ) { - synchronized(this) { - currentState = newState - _stateFlow.value = newState - } + currentState = newState subscription.notifyListeners { onStateChanged(newState) } } } - override val flow: StateFlow = _stateFlow.asStateFlow() - - @Synchronized - override fun getCurrentState(): FlagsClientState = currentState - override fun addListener(listener: FlagsStateListener) { subscription.addListener(listener) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt index e348771ff2..8b376033c5 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt @@ -14,9 +14,6 @@ import com.datadog.android.flags.model.ErrorCode import com.datadog.android.flags.model.EvaluationContext import com.datadog.android.flags.model.FlagsClientState import com.datadog.android.flags.model.ResolutionDetails -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import org.json.JSONObject /** @@ -39,9 +36,7 @@ internal class NoOpFlagsClient( ) : FlagsClient { override val state: StateObservable = object : StateObservable { - private val _stateFlow = MutableStateFlow(FlagsClientState.Ready) - override val flow: StateFlow = _stateFlow.asStateFlow() - override fun getCurrentState(): FlagsClientState = FlagsClientState.Ready + override fun getCurrentState(): FlagsClientState = FlagsClientState.Error(null) override fun addListener(listener: FlagsStateListener) { /* no-op */ } override fun removeListener(listener: FlagsStateListener) { /* no-op */ } } diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt index 103cce3a32..0dec102441 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/NoOpFlagsClientTest.kt @@ -432,13 +432,12 @@ internal class NoOpFlagsClientTest { // region State Management @Test - fun `M return ready state W state_getCurrentState()`() { + fun `M return error state W state_getCurrentState()`() { // When val state = testedClient.state.getCurrentState() // Then - // NoOp client is always "ready" to return default values - assertThat(state).isEqualTo(FlagsClientState.Ready) + assertThat(state).isInstanceOf(FlagsClientState.Error::class.java) } @Test diff --git a/features/dd-sdk-android-flags/transitiveDependencies b/features/dd-sdk-android-flags/transitiveDependencies index 9ee447485b..c4394247cc 100644 --- a/features/dd-sdk-android-flags/transitiveDependencies +++ b/features/dd-sdk-android-flags/transitiveDependencies @@ -8,8 +8,7 @@ com.squareup.okio:okio-jvm:3.6.0 : 351 Kb org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10 : 959 b org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10 : 965 b org.jetbrains.kotlin:kotlin-stdlib:2.0.21 : 1706 Kb -org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.4.2 : 1635 Kb org.jetbrains:annotations:13.0 : 17 Kb -Total transitive dependencies size : 5 Mb +Total transitive dependencies size : 3 Mb From 740d7e7d9acd10fcc07c006e2e849524034c5acb Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Fri, 28 Nov 2025 10:14:58 -0700 Subject: [PATCH 26/28] no need to sync --- .../com/datadog/android/flags/internal/FlagsStateManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt index 4b08d9ff1a..5aa24aac11 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/FlagsStateManager.kt @@ -75,7 +75,7 @@ internal class FlagsStateManager( operationName = NOTIFY_NEW_LISTENER_OPERATION_NAME, internalLogger = internalLogger ) { - val state = synchronized(this) { currentState } + val state = currentState listener.onStateChanged(state) } } From 952b5b89a6ccb34b9943c249a8154246fc18d41f Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Mon, 1 Dec 2025 09:11:29 -0700 Subject: [PATCH 27/28] Fix comments, tests, safe calls --- detekt_custom_safe_calls.yml | 2 - .../com/datadog/android/flags/FlagsClient.kt | 4 - .../android/flags/internal/NoOpFlagsClient.kt | 4 +- .../com/datadog/android/flags/FlagsTest.kt | 3 +- .../flags/internal/DatadogFlagsClientTest.kt | 4 +- .../flags/internal/FlagsStateManagerTest.kt | 18 +-- .../repository/DefaultFlagsRepositoryTest.kt | 143 +++++++++--------- 7 files changed, 77 insertions(+), 101 deletions(-) diff --git a/detekt_custom_safe_calls.yml b/detekt_custom_safe_calls.yml index 5dde086f3f..dec5d49f37 100644 --- a/detekt_custom_safe_calls.yml +++ b/detekt_custom_safe_calls.yml @@ -1083,8 +1083,6 @@ datadog: - "kotlinx.coroutines.flow.FlowCollector.emit(kotlin.Any?)" - "kotlinx.coroutines.flow.FlowCollector(kotlin.coroutines.SuspendFunction1)" - "kotlinx.coroutines.flow.flow(kotlin.coroutines.SuspendFunction1)" - - "kotlinx.coroutines.flow.MutableStateFlow(com.datadog.android.flags.model.FlagsClientState)" - - "kotlinx.coroutines.flow.MutableStateFlow.asStateFlow()" # endregion # region Kronos - "com.lyft.kronos.AndroidClockFactory.createKronosClock(android.content.Context, com.lyft.kronos.SyncListener?, kotlin.collections.List, kotlin.Long, kotlin.Long, kotlin.Long, kotlin.Long)" diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt index e90b8306a6..b7c49aefba 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/FlagsClient.kt @@ -155,7 +155,6 @@ interface FlagsClient { * * Provides three ways to observe state: * - Synchronous: [StateObservable.getCurrentState] for immediate queries (Java-friendly) - * - Reactive: [StateObservable.flow] for coroutine-based updates (Kotlin) * - Callback: [StateObservable.addListener] for traditional observers (Java-friendly) * * Example: @@ -163,9 +162,6 @@ interface FlagsClient { * // Synchronous * val current = client.state.getCurrentState() * - * // Reactive Flow - * client.state.flow.collect { state -> /* ... */ } - * * // Callback * client.state.addListener(listener) * ``` diff --git a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt index 8b376033c5..e58d37ac03 100644 --- a/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt +++ b/features/dd-sdk-android-flags/src/main/kotlin/com/datadog/android/flags/internal/NoOpFlagsClient.kt @@ -37,8 +37,8 @@ internal class NoOpFlagsClient( override val state: StateObservable = object : StateObservable { override fun getCurrentState(): FlagsClientState = FlagsClientState.Error(null) - override fun addListener(listener: FlagsStateListener) { /* no-op */ } - override fun removeListener(listener: FlagsStateListener) { /* no-op */ } + override fun addListener(listener: FlagsStateListener) = Unit + override fun removeListener(listener: FlagsStateListener) = Unit } /** diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsTest.kt index 08e96095d4..830885b6a8 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsTest.kt @@ -58,8 +58,7 @@ internal class FlagsTest { @BeforeEach fun `set up`() { whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger - whenever(mockSdkCore.createSingleThreadExecutorService(org.mockito.kotlin.any())) doReturn - mockExecutorService + whenever(mockSdkCore.createSingleThreadExecutorService(org.mockito.kotlin.any())) doReturn mockExecutorService whenever(mockDatadogContext.clientToken) doReturn fakeClientToken whenever(mockDatadogContext.site) doReturn DatadogSite.US1 diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt index 3600c44f33..dd56a7e10e 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/DatadogFlagsClientTest.kt @@ -1316,7 +1316,7 @@ internal class DatadogFlagsClientTest { @Test fun `M delegate to state manager W state_addListener()`() { // Given - val mockListener = mock(FlagsStateListener::class.java) + val mockListener = mock() // When testedClient.state.addListener(mockListener) @@ -1329,7 +1329,7 @@ internal class DatadogFlagsClientTest { @Test fun `M delegate to state manager W state_removeListener()`() { // Given - val mockListener = mock(FlagsStateListener::class.java) + val mockListener = mock() // When testedClient.state.removeListener(mockListener) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateManagerTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateManagerTest.kt index 4f70ba2b78..ff5e02267b 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateManagerTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/FlagsStateManagerTest.kt @@ -23,6 +23,7 @@ import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.times import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever import org.mockito.quality.Strictness import java.util.concurrent.ExecutorService @@ -85,21 +86,6 @@ internal class FlagsStateManagerTest { // region addListener / removeListener - @Test - fun `M notify listener W addListener() and notify`() { - // Given - testedManager.addListener(mockListener) - - // When - testedManager.updateState(FlagsClientState.Ready) - - // Then - inOrder(mockListener) { - verify(mockListener).onStateChanged(FlagsClientState.NotReady) // Current state on add - verify(mockListener).onStateChanged(FlagsClientState.Ready) // State update - } - } - @Test fun `M not notify listener after removal W removeListener() and notify`() { // Given @@ -113,7 +99,7 @@ internal class FlagsStateManagerTest { testedManager.updateState(FlagsClientState.Ready) // Then - no further notifications after removal - org.mockito.kotlin.verifyNoMoreInteractions(mockListener) + verifyNoMoreInteractions(mockListener) } @Test diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt index 667fe590a6..205b15b9e3 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/internal/repository/DefaultFlagsRepositoryTest.kt @@ -52,8 +52,12 @@ internal class DefaultFlagsRepositoryTest { private lateinit var testedRepository: DefaultFlagsRepository + private lateinit var testContext: EvaluationContext + private lateinit var singleFlagMap: Map + private lateinit var multipleFlagsMap: Map + @BeforeEach - fun `set up`() { + fun `set up`(forge: Forge) { whenever(mockFeatureSdkCore.internalLogger) doReturn mockInternalLogger whenever( mockDataStore.value( @@ -73,6 +77,42 @@ internal class DefaultFlagsRepositoryTest { dataStore = mockDataStore, instanceName = "default" ) + + // Setup test fixtures for hasFlags tests + testContext = EvaluationContext(forge.anAlphabeticalString(), emptyMap()) + + singleFlagMap = mapOf( + forge.anAlphabeticalString() to PrecomputedFlag( + variationType = "string", + variationValue = forge.anAlphabeticalString(), + doLog = false, + allocationKey = forge.anAlphabeticalString(), + variationKey = forge.anAlphabeticalString(), + extraLogging = JSONObject(), + reason = "DEFAULT" + ) + ) + + multipleFlagsMap = mapOf( + forge.anAlphabeticalString() to PrecomputedFlag( + variationType = "string", + variationValue = forge.anAlphabeticalString(), + doLog = false, + allocationKey = forge.anAlphabeticalString(), + variationKey = forge.anAlphabeticalString(), + extraLogging = JSONObject(), + reason = "DEFAULT" + ), + forge.anAlphabeticalString() to PrecomputedFlag( + variationType = "boolean", + variationValue = "true", + doLog = false, + allocationKey = forge.anAlphabeticalString(), + variationKey = forge.anAlphabeticalString(), + extraLogging = JSONObject(), + reason = "TARGETING_MATCH" + ) + ) } @Test @@ -188,88 +228,44 @@ internal class DefaultFlagsRepositoryTest { // region hasFlags @Test - fun `M return expected value W hasFlags() { for various states }`(forge: Forge) { - data class TestCase(val given: () -> Unit, val then: Boolean) + fun `M return false W hasFlags() { no state set }`() { + // When + Then + assertThat(testedRepository.hasFlags()).isFalse() + } - val testCases = listOf( - TestCase( - given = { /* no state set */ }, - then = false - ), - TestCase( - given = { - testedRepository.setFlagsAndContext( - EvaluationContext(forge.anAlphabeticalString(), emptyMap()), - emptyMap() - ) - }, - then = false - ), - TestCase( - given = { - testedRepository.setFlagsAndContext( - EvaluationContext(forge.anAlphabeticalString(), emptyMap()), - mapOf( - forge.anAlphabeticalString() to PrecomputedFlag( - variationType = "string", - variationValue = forge.anAlphabeticalString(), - doLog = false, - allocationKey = forge.anAlphabeticalString(), - variationKey = forge.anAlphabeticalString(), - extraLogging = JSONObject(), - reason = "DEFAULT" - ) - ) - ) - }, - then = true - ), - TestCase( - given = { - testedRepository.setFlagsAndContext( - EvaluationContext(forge.anAlphabeticalString(), emptyMap()), - mapOf( - forge.anAlphabeticalString() to PrecomputedFlag( - variationType = "string", - variationValue = forge.anAlphabeticalString(), - doLog = false, - allocationKey = forge.anAlphabeticalString(), - variationKey = forge.anAlphabeticalString(), - extraLogging = JSONObject(), - reason = "DEFAULT" - ), - forge.anAlphabeticalString() to PrecomputedFlag( - variationType = "boolean", - variationValue = "true", - doLog = false, - allocationKey = forge.anAlphabeticalString(), - variationKey = forge.anAlphabeticalString(), - extraLogging = JSONObject(), - reason = "TARGETING_MATCH" - ) - ) - ) - }, - then = true - ) + @Test + fun `M return false W hasFlags() { empty flags map }`(forge: Forge) { + // Given + testedRepository.setFlagsAndContext( + EvaluationContext(forge.anAlphabeticalString(), emptyMap()), + emptyMap() ) - testCases.forEach { testCase -> - // Given - testCase.given() + // When + Then + assertThat(testedRepository.hasFlags()).isFalse() + } - // When - val result = testedRepository.hasFlags() + @Test + fun `M return true W hasFlags() { single flag }`() { + // Given + testedRepository.setFlagsAndContext(testContext, singleFlagMap) - // Then - assertThat(result).isEqualTo(testCase.then) - } + // When + Then + assertThat(testedRepository.hasFlags()).isTrue() + } + + @Test + fun `M return true W hasFlags() { multiple flags }`() { + // Given + testedRepository.setFlagsAndContext(testContext, multipleFlagsMap) + + // When + Then + assertThat(testedRepository.hasFlags()).isTrue() } @Test fun `M not block W hasFlags() { persistence still loading }`() { // Given - val startTime = System.currentTimeMillis() doAnswer { // Never call the callback - simulate slow persistence null @@ -287,6 +283,7 @@ internal class DefaultFlagsRepositoryTest { ) // When + val startTime = System.currentTimeMillis() val result = slowRepository.hasFlags() val elapsedTime = System.currentTimeMillis() - startTime From c8b1143780250f2cbc0edba67ec8a30b15c4ee3c Mon Sep 17 00:00:00 2001 From: Tyler Potter Date: Mon, 1 Dec 2025 09:47:15 -0700 Subject: [PATCH 28/28] apply suggestion --- .../src/test/kotlin/com/datadog/android/flags/FlagsTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsTest.kt b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsTest.kt index 830885b6a8..13b50a14a4 100644 --- a/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsTest.kt +++ b/features/dd-sdk-android-flags/src/test/kotlin/com/datadog/android/flags/FlagsTest.kt @@ -24,6 +24,7 @@ import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock @@ -58,7 +59,7 @@ internal class FlagsTest { @BeforeEach fun `set up`() { whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger - whenever(mockSdkCore.createSingleThreadExecutorService(org.mockito.kotlin.any())) doReturn mockExecutorService + whenever(mockSdkCore.createSingleThreadExecutorService(any())) doReturn mockExecutorService whenever(mockDatadogContext.clientToken) doReturn fakeClientToken whenever(mockDatadogContext.site) doReturn DatadogSite.US1