Mobile SDKs

Android SDK

Install and configure the Android SDK, evaluate configs and feature flags, and keep your UI current as values change

Introduction

The Android SDK is intended to be used by Android applications. It evaluates your configs against a user context, keeps them current as they change in the dashboard, and hands your UI values it can re-render from.

It is written in Kotlin and is meant to be used from Kotlin and Java alike. Every API has a form that works without coroutines, and the Kotlin conveniences — suspend functions, Flow, and typed extensions — sit on top of it. It ships as three artifacts:

ArtifactContentsMinimum SDKJava bytecode
com.configdirector:android-sdkThe SDK218
com.configdirector:android-sdk-composeOptional Jetpack Compose bindings2111
com.configdirector:android-sdk-testingTools for testing the code that reads your configs218

The Compose artifact depends only on the Compose runtime, so it does not pull compose-ui or Material into an application that does not already have them.

Config evaluation is synchronous and reads config state the client already holds in memory, which is what makes it safe to call from a composable or from a view binding. Telemetry about those evaluations is aggregated and reported on its own coroutine, so evaluating a config never waits on the network.

Installation

The SDK is published to the ConfigDirector Maven repository. Add the repository to settings.gradle.kts or settings.gradle, next to google() and mavenCentral():

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        exclusiveContent {
            forRepository {
                maven {
                    name = "ConfigDirector"
                    url = uri("https://maven.configdirector.com")
                }
            }
            filter {
                includeGroup("com.configdirector")
            }
        }
    }
}

Then add the dependencies to your app module:

dependencies {
    implementation("com.configdirector:android-sdk:1.7.1")

    // Optional, for Jetpack Compose applications
    implementation("com.configdirector:android-sdk-compose:1.7.1")
}

The SDK declares the INTERNET permission, which is merged into your application's manifest, so there is nothing to add there.

Configure and initialize the client

  1. Create an instance of the client providing an Android context and your client SDK key. You can retrieve a client SDK key for each environment under SDK keys in the dashboard's navigation panel.
  2. Initialize the client to initiate its connection lifecycle.
import com.configdirector.ConfigDirectorClient

// Creating the client makes no network calls. It throws only when the SDK key is
// blank or an option holds an unusable value.
val client = ConfigDirectorClient(applicationContext, "YOUR-CLIENT-SDK-KEY")

// Connects and waits until config values are received, or until it times out.
// After a timeout the client keeps trying to connect in the background.
client.initialize()

The client is thread safe. Create one and share it for the lifetime of the app.

initialize is a suspend function in Kotlin, so it is called from a coroutine. The overload taking a CompletionCallback is the one to use from Java, and from anywhere in Kotlin that has no coroutine to run in.

Before the client is initialized, config values will evaluate to the default value provided in code.

The constructor throws a ConfigDirectorValidationException in only two cases, both of which are programming errors rather than runtime conditions: a blank client SDK key, and an option that cannot be used, such as a negative timeout or a base URL that is not absolute. Connection failures are not thrown — initialize returns normally whether or not config state arrived, and isReady tells you which happened.

Most applications create a single client instance and initialize it during startup. That usually means owning it in the Application, so every screen shares one client:

import android.app.Application
import com.configdirector.ConfigDirectorClient
import com.configdirector.ConfigDirectorContext
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.launch

class MyApplication : Application() {

    lateinit var client: ConfigDirectorClient
        private set

    override fun onCreate() {
        super.onCreate()

        client = ConfigDirectorClient(this, "YOUR-CLIENT-SDK-KEY")

        MainScope().launch {
            client.initialize(ConfigDirectorContext.build { id("user-123") })
        }
    }
}
The client watches the application behind the context it was given so it can release its network connection while the app is backgrounded. Pass it any context — the application behind it is what matters. If the context does not belong to an Application, the SDK logs a warning and leaves the connection running for you to manage with pauseNetwork() and resumeNetwork().

Nothing needs to close the client: it lives as long as the process, and Android reclaims everything when the process ends. Call close() when an application wants the client gone before that, on sign-out for instance.

Additional configuration options

These options can be passed in to ClientOptions, which is built with ClientOptions.build { } from Kotlin and ClientOptions.builder() from Java. They are read once when the client is created; changing them afterwards has no effect.

metadata

The metadata option allows you to provide your application's name and version. These values can be used in targeting rules conditionals. For example, if a certain feature should only be enabled starting with a certain version of your application.

Whichever of the two you leave unset is read from the application when the client is created — the name from the application label and the version from versionName — so most applications do not need to set either one. When one cannot be read, the client says so at the INFO log level.

val client = ConfigDirectorClient(
    applicationContext,
    "YOUR-CLIENT-SDK-KEY",
    ClientOptions.build {
        metadata("YOUR-APP-NAME", "1.0.2")
    },
)

logger

By default, the SDK logs to logcat under the ConfigDirector tag, and it is set to log warnings and errors only. You can configure a logger by either creating an AndroidLogger with a different log level, or by implementing the ConfigDirectorLogger interface to provide your own logger. The interface can be used to create an adapter to another logging library.

Configure the default logger to a different level:

val client = ConfigDirectorClient(
    applicationContext,
    "YOUR-CLIENT-SDK-KEY",
    ClientOptions.build { logger(AndroidLogger(LogLevel.DEBUG)) },
)

The available levels are OFF, ERROR, WARN (the default), INFO and DEBUG.

Implement your own logger adapter:

import com.configdirector.ConfigDirectorLogger
import com.configdirector.LogLevel

class MyLogger : ConfigDirectorLogger {
    // Messages more verbose than this level are never passed to `log`.
    override val level: LogLevel = LogLevel.INFO

    override fun log(level: LogLevel, message: String, error: Throwable?) {
        // your specific logging library implementation here
    }
}

val client = ConfigDirectorClient(
    applicationContext,
    "YOUR-CLIENT-SDK-KEY",
    ClientOptions.build { logger(MyLogger()) },
)

connection

ConnectionOptions accepts five optional values:

  • mode
    • The connection mode, which can be ConnectionMode.STREAMING or ConnectionMode.POLLING. It is recommended to use the default of STREAMING unless you have a specific need to use POLLING instead.
    • STREAMING holds a connection open and receives changes as they happen, reconnecting on its own with a backoff.
    • Defaults to ConnectionMode.STREAMING
  • pollingIntervalMillis
    • How often to re-fetch config state, in milliseconds, when mode is ConnectionMode.POLLING. It has no effect in any other mode. A value below the minimum of 30_000 (30 seconds) is raised to 30_000 and a warning is logged. See Polling intervals.
    • Defaults to 60_000 (60 seconds)
  • timeoutMillis
    • The timeout to be used in initialization and when updating the context. This is how long initialize will wait for data from ConfigDirector services before returning. If the timeout is reached, initialize will return but the client will still be in an unready status and returning default values. The client will continue to attempt to connect and retrieve config values in the background.
    • If your application is used in environments where the users frequently have poor or no connection, you may want to use a lower timeout, or simply don't await the initialize call.
    • Defaults to 3_000 (3 seconds)
  • pausesWhileBackgrounded
    • Whether to pause the connection while the app is in the background and resume it when the app returns to the foreground. Android stops background connections on its own, so this is enabled by default. Set it to false to manage the connection yourself with pauseNetwork() and resumeNetwork().
    • Defaults to true
  • baseUrl
    • The base URL used to connect to ConfigDirector services.
    • This should only be provided if your environment requires you to configure a proxy server in order to connect to ConfigDirector services.
val client = ConfigDirectorClient(
    applicationContext,
    "YOUR-CLIENT-SDK-KEY",
    ClientOptions.build {
        connection {
            mode(ConnectionMode.POLLING)
            pollingIntervalMillis(600_000L)
            timeoutMillis(2_000L)
        }
    },
)

Settings are checked as they are built, so an unusable one throws a ConfigDirectorValidationException where it was written rather than becoming a client that quietly never updates.

Events

Events notify you of some key actions from the client. addEventListener registers a listener for the client's lifecycle changes, and addEvaluationListener registers one for every individual config evaluation. Both are called on the main thread, and both return a Subscription you close to stop listening. Kotlin callers have the same two as flows, client.events and client.evaluations; each collection is independent, so several parts of your app can observe them at once, and every flow completes when the client is closed.

import com.configdirector.ClientEvent
import com.configdirector.events

lifecycleScope.launch {
    client.events.collect { event ->
        when (event) {
            is ClientEvent.Ready -> Log.i("MyApp", "Client is ready after ${event.reason}")
            is ClientEvent.ConfigsUpdated -> Log.i("MyApp", "Configs updated: ${event.keys}")
            is ClientEvent.ContextUpdated -> Log.i("MyApp", "Context updated: ${event.context?.id}")
        }
    }
}

The following events are published to an event listener:

  • ClientEvent.Ready
    • Emitted when the client is initialized, or after reconnection due to a context update or a resumed network connection. When this event is emitted it means the client has received a payload from the ConfigDirector servers and is ready to evaluate configs.
    • reason - A ConnectReason, the action that triggered the client to connect/reconnect, which can be ConnectReason.INITIALIZATION, ConnectReason.CONTEXT_UPDATE, or ConnectReason.NETWORK_RESUME.
  • ClientEvent.ConfigsUpdated
    • Emitted when a payload is received from the ConfigDirector servers with config data. It is emitted during initialization or context update when an entire config payload is received. After initialization or context update, it is emitted when updates are pushed from the server (or discovered via polling if that connection mode is used).
    • keys - A List<String> listing the config keys that were included in the payload from the server. On a delta update, these are only the configs that changed.
    • removedKeys - A List<String> listing the keys of configs the payload removed. A config is removed when a full payload no longer includes it. Empty when nothing was removed, and always empty on a delta update.
  • ClientEvent.ContextUpdated
    • Emitted whenever the user context takes effect, including during initialization. It is emitted before ClientEvent.Ready, therefore should not be relied upon as a lifecycle event but rather as an informational event to track updates to the user context.
    • context - The ConfigDirectorContext the client is now evaluating against. It can be null.

An evaluation listener is handed a ConfigEvaluation every time a config is evaluated, which can be useful for debugging what a screen actually read, and for capturing additional context for analytics events. This includes every read through a getter and every re-evaluation a watch makes. A ConfigEvaluation carries:

  • key: String - The config key that was evaluated.
  • value: Any - The value the config evaluated to. It can be the default value provided by the caller, for example if the config was evaluated before the client was initialized. It is a Boolean, String, Integer or Double, matching the accessor the config was read with.
  • valueId: String? - The value ID, which is a stable hash of the value that can be used for analytics or other third parties rather than sending the value. This can be useful for large values, like a JSON config, or to avoid disclosing the values themselves to third parties. It is null if the evaluation had to fall back to the default value.
  • isDefaultValue: Boolean - Whether or not the evaluation fell back to the default value provided in code.
  • context: ConfigDirectorContext? - The context the config was evaluated against, if one was set.
  • reason: EvaluationReason - The reason for the evaluation resolution. In the case the config successfully evaluated based on server-provided targeting rules, it will be FOUND_MATCH. If the evaluation had to fall back to the default value, the reason will encode why the fallback was required:
    • CLIENT_NOT_READY - The evaluation happened before the client finished initialization.
    • CONFIG_STATE_MISSING - The requested config key was not present in the payload received from the server. This could be due to an incorrect config key, or a config that was not enabled to be available to client SDKs.
    • VALUE_MISSING - The config had no value for the current context.
    • INVALID_NUMBER - The config was requested as a number but the value received from the server could not be converted to a number.
    • INVALID_BOOLEAN - The config was requested as a boolean but the value received from the server could not be converted to a boolean.
    • INVALID_JSON - The config was requested as JSON but the value received from the server could not be read as a JSON document.
    • TYPE_MISMATCH - The config was requested with a data type that did not match the type of the config and no reasonable type conversion was possible.
ConfigDirectorSetup.kt
import com.configdirector.evaluations

lifecycleScope.launch {
    client.evaluations
        .filter { it.isDefaultValue }
        .collect { Log.w("MyApp", "'${it.key}' fell back to its default: ${it.reason.wireName}") }
}
Do not read a config from an evaluation listener, and do not drive state from evaluations that a composable reads a config from. The read publishes another evaluation, which recomposes, which reads the config again.

Retrieve config values

To synchronously retrieve config values, use the client's typed getters: getBoolean, getString, getInt, getDouble, getJsonObject and getJsonArray. Each requires two arguments. The first argument is the config key, and the second is the default value to be returned if the client has not yet received config values from ConfigDirector services. Kotlin callers also have client.value(key, default), which takes the type from the default value.

You can also subscribe to config value changes via the matching watches: watchBoolean, watchString, watchInt, watchDouble, watchJsonObject and watchJsonArray. Each takes the config key, the default value, and a listener that is handed the config's current value straight away and then every time the evaluated value changes, whether because it was edited in the ConfigDirector dashboard or because the context was updated. Consecutive identical values are not delivered again. When a full payload no longer includes the watched config, the listener is handed the default value, as a getter would now return it. Close the returned Subscription to stop watching. In Kotlin, client.values(key, default) is the same thing as a Flow, and cancelling the collector stops watching.

import com.configdirector.value
import com.configdirector.values

// Retrieve the current value. The default value decides the type the config
// is read as, and is returned until the client is ready.
val darkMode = client.value("my-config-key", false)

// Subscribe to value updates. The flow emits the current value right away
// and then every time the evaluated value changes.
lifecycleScope.launch {
    client.values("my-config-key", false).collect { darkMode ->
        Log.i("MyApp", "Value updated: $darkMode")
    }
}

There is one getter and one watch per type a config can be read as, so a default value of any other type is a compile error rather than a failure at runtime. If the served value does not match the type it is read as, the SDK will attempt to convert it. If the conversion fails, it will return the default value. Mismatched types at runtime are captured by the telemetry collector and will surface as warnings in the ConfigDirector dashboard.

MainActivity.kt
client.getString("my-string-config-key", "Default")

client.getInt("my-integer-config-key", 100)

client.getDouble("my-decimal-config-key", 1.5)

client.getBoolean("my-boolean-config-key", false)

A string, enum, URL, or custom config can be read as a string, and so can a JSON config, as its raw document. A boolean, integer, or float config read as a string evaluates to the default value with the TYPE_MISMATCH reason. A value written as a decimal and read with getInt is truncated.

JSON configs

A config declared as JSON in the ConfigDirector dashboard is read with getJsonObject or getJsonArray, depending on the shape of its document. The values inside are String, Number, Boolean, List, Map, or null, and the document cannot be modified. Only a JSON config reads as one; read it as a string to get its raw document.

val theme = client.getJsonObject("theme", mapOf("primary" to "#101010"))
val primary = theme["primary"] as? String

val regions = client.getJsonArray("regions", listOf("us-east"))

Reading configs in Jetpack Compose

The android-sdk-compose artifact reads configs as Compose state, so a composable recomposes on its own whenever an evaluated value changes.

Wrap your content in a ConfigDirectorProvider and every binding below it reads from that client:

MainActivity.kt
import com.configdirector.compose.ConfigDirectorProvider

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val client = (application as MyApplication).client
        setContent {
            ConfigDirectorProvider(client) {
                MaterialTheme { HomeScreen() }
            }
        }
    }
}

configValue then reads a config anywhere inside it, with one overload per type a config can be read as. Until the client is ready, and for a value that cannot be read as the requested type, it is the default value:

HomeScreen.kt
import com.configdirector.compose.configContext
import com.configdirector.compose.configValue
import com.configdirector.compose.isClientReady

@Composable
fun HomeScreen() {
    val darkMode = configValue("dark-mode", false)
    val greeting = configValue("greeting", "Hello")

    // Recomposes when config state arrives, which is useful to show a loading
    // state rather than flashing the in-code default value on a slow connection.
    if (!isClientReady()) {
        LoadingScreen()
        return
    }

    Text(text = greeting, color = if (darkMode) Color.White else Color.Black)
}

isClientReady() and configContext() expose the client's readiness and its current context as state, recomposing when either changes.

Each binding takes the client from LocalConfigDirectorClient unless one is passed to it explicitly. Reading a binding with no provider above it in the tree fails rather than serving default values that look like real ones.

Reading configs with framework views

With framework views, register a watch when the view is created and close its subscription when the view goes away:

MainActivity.kt
class MainActivity : AppCompatActivity() {

    private var subscription: Subscription? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val client = (application as MyApplication).client
        subscription = client.watchBoolean("dark-mode", false) { darkMode ->
            // Called on the main thread with the current value, and on every change.
            findViewById<Switch>(R.id.dark_mode).isChecked = darkMode
        }
    }

    override fun onDestroy() {
        subscription?.close()
        super.onDestroy()
    }
}

Update the user context

A user context can be provided when initializing the client:

import com.configdirector.ConfigDirectorClient
import com.configdirector.ConfigDirectorContext

val client = ConfigDirectorClient(applicationContext, "YOUR-CLIENT-SDK-KEY")

client.initialize(
    ConfigDirectorContext.build {
        id("12345")
        name("Example User")
        // Any arbitrary traits which can be referenced in targeting rules
        trait("region", "North America")
    },
)

The user context can also be updated via updateContext:

import com.configdirector.ConfigDirectorContext

client.updateContext(
    ConfigDirectorContext.build {
        id("654321")
        name("Another User")
        trait("region", "Australia")
    },
)

// Update it to an anonymous context when a user signs out. Leaving `id` unset
// makes the server derive a stable identifier from this SDK instance.
client.updateContext(ConfigDirectorContext.build { anonymous(true) })
In client SDKs (browser and mobile), updating the user context re-establishes a new connection to ConfigDirector servers with the new context. While the new connection is in flight, config values will continue to evaluate to the currently cached values from the prior user context.
Awaiting updateContext will wait until the new config values are downloaded or the connection times out. In the case of a timeout, the client will continue to attempt to connect with the new context in the background.

Traits are JSON-shaped values: String, Number, Boolean, List, Map, or null. A trait of any other type could never match a targeting rule, so building the context throws a ConfigDirectorValidationException rather than sending something the server cannot evaluate.

MainActivity.kt
client.updateContext(
    ConfigDirectorContext.build {
        id("user-123")
        name("Ada Lovelace")
        traits(
            mapOf(
                "plan" to "pro",
                "seats" to 12,
                "beta" to true,
                "regions" to listOf("us-east", "eu-west"),
            ),
        )
    },
)

For a signed-out user, set anonymous to true. The values are still used to evaluate targeting rules, but the context is not persisted and does not appear in the dashboard. Leaving id unset makes the ConfigDirector server derive an identifier from the SDK instance, so the user keeps the same place in percentage rollouts for as long as that client instance lives; a new client instance gets a new identifier, which can move the user into a different percentile. Pass your own id when a rollout must stay stable across app launches.

Other useful client features

The client provides additional properties and methods.

context

Returns the current user context (which may be null). When calling updateContext, the context is not immediately updated. The context is updated after the new connection for the new context succeeds (or times out and goes on retry).

isReady

Returns a Boolean indicating if the client has been successfully initialized and is ready to evaluate configs for the given user context. It is initially false and becomes true after initialize succeeds. Upon calling updateContext it becomes false again and it is set to true once updateContext succeeds.

isInitializing

Returns a Boolean indicating whether the client is trying to get its very first config state from the server. It is false on creation, becomes true when initialize is called on a client that has never received config state, stays true through timeouts and retries, and becomes false when the first config state arrives, when an unrecoverable connection error stops the retries, or on close. updateContext and resumeNetwork never set it. This is useful to show a loading state instead of transitioning from the in-code default value to the evaluated value on a slow connection.

pauseNetwork() and resumeNetwork()

pauseNetwork() releases the network connection without discarding config state, watches, or listeners, and resumeNetwork() re-establishes it using the last context given to initialize or updateContext. Reads keep serving the last config state the client received, and isReady is false until the connection is back.

The client already does this on its own while the app is backgrounded, so these are only needed if you set pausesWhileBackgrounded to false in order to manage the connection yourself.

resumeNetwork has the same two forms as initialize: a suspend function for Kotlin, and one taking a CompletionCallback.

close()

Closes the connection, every watch, and every listener registration, and reports whatever telemetry is left. The client cannot be used afterwards: it stops receiving config state and never becomes ready again, though reads keep serving the last config state it received.

Most applications never need to call it — the client lives as long as the process, and Android reclaims everything when the process ends. Call it when an application wants the client gone before that, on sign-out for instance.

Test your code

Use the SDK's testing artifact, com.configdirector:android-sdk-testing, to test the code that reads your configs and flags. It creates a test client: the SDK's real client connected to an in-memory server that your test controls. Only the connection to ConfigDirector, the telemetry, and the app lifecycle observer are replaced. Everything else is the same code that runs in production, so the code under test behaves exactly as it does against ConfigDirector, and no network connection is opened, no telemetry is sent, and no Android Context is needed. It runs in a plain JVM unit test, under Robolectric, and in an instrumented test alike.

The artifact is released together with the SDK and must be the same version as the android-sdk on your test classpath; createTestClient refuses any other. Add it in test scope:

dependencies {
    testImplementation("com.configdirector:android-sdk-testing:1.7.1")
    // For a Main dispatcher in plain JVM tests, see below
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
}
CheckoutTest.kt
import com.configdirector.testing.createTestClient

class CheckoutTest {

    @Test
    fun `enables the new checkout when the flag is on`() = runBlocking {
        val testClient = createTestClient(values = mapOf("new-checkout" to true, "max-items" to 20))
        testClient.client.initialize()

        val checkout = Checkout(testClient.client)
        assertThat(checkout.isNewCheckoutEnabled).isTrue()

        testClient.setValue("new-checkout", false)
        assertThat(checkout.isNewCheckoutEnabled).isFalse()

        testClient.client.close()
    }
}

testClient.client is a ConfigDirectorClient, so it goes anywhere your code accepts one. It starts uninitialized, like a production client, because the code under test usually owns the call to initialize; with nothing held or failed, initialize completes at once with the client ready. Close it when the test is done, as your production code would.

From Java, the factory is ConfigDirectorTesting.createTestClient(values), with overloads that also take the timeout and the logger. The callback overloads of the client complete on the Main dispatcher, so a plain JVM test sets one first (see below):

CheckoutTest.java
import com.configdirector.testing.ConfigDirectorTesting;
import com.configdirector.testing.TestClient;

TestClient testClient = ConfigDirectorTesting.createTestClient(Collections.singletonMap("new-checkout", true));
CountDownLatch initialized = new CountDownLatch(1);
testClient.getClient().initialize(null, initialized::countDown);
initialized.await(5, TimeUnit.SECONDS);

Checkout checkout = new Checkout(testClient.getClient());
assertThat(checkout.isNewCheckoutEnabled()).isTrue();

testClient.setValue("new-checkout", false);
assertThat(checkout.isNewCheckoutEnabled()).isFalse();

testClient.getClient().close();

The Main dispatcher

The client hands listeners, watches, the values and events flows, the Compose bindings, and the Java callback overloads back on the Main dispatcher. Robolectric and instrumented tests have one. A plain JVM test does not, so it sets one from kotlinx-coroutines-test. With UnconfinedTestDispatcher, watches and listeners run inline, so a watch has seen a setValue by the time it returns:

@Before
fun setUpMainDispatcher() {
    Dispatchers.setMain(UnconfinedTestDispatcher())
}

@After
fun tearDown() {
    testClient.client.close()
    Dispatchers.resetMain()
}

From Java, call TestDispatchers.setMain(Dispatchers.INSTANCE, Dispatchers.getDefault()) before the test and TestDispatchers.resetMain(Dispatchers.INSTANCE) after it. Reads through getBoolean and the other getters need no Main dispatcher. Without one, the first client call that needs it, such as registering a watch or initialize, fails with an IllegalStateException saying that Dispatchers.Main is missing.

In Jetpack Compose

A composable needs a composition to run in, which on the JVM means Robolectric with createComposeRule, or an instrumented test. Wrap the content in the production ConfigDirectorProvider with the test client's client, as the app does. The client hands values to the bindings on the main looper, which Robolectric only runs when the test lets it, so idle it after initialize and after every change:

HomeScreenTest.kt
import com.configdirector.compose.ConfigDirectorProvider
import com.configdirector.testing.createTestClient

@RunWith(RobolectricTestRunner::class)
class HomeScreenTest {

    @get:Rule
    val compose = createComposeRule()

    private val testClient = createTestClient(values = mapOf("greeting" to "hello"))

    @After
    fun tearDown() = testClient.client.close()

    private fun settle() {
        shadowOf(Looper.getMainLooper()).idle()
        compose.waitForIdle()
    }

    @Test
    fun `shows the greeting from the config`() {
        compose.setContent { ConfigDirectorProvider(testClient.client) { HomeScreen() } }
        runBlocking { testClient.client.initialize() }
        settle()
        compose.onNodeWithText("hello").assertIsDisplayed()

        testClient.setValue("greeting", "hi")
        settle()
        compose.onNodeWithText("hi").assertIsDisplayed()
    }
}

Values

values, setValue, and replaceValues take native values, and the config type follows from each value's type: a Boolean; an Int or Long (an integer config); a Float or Double (a float config); a String; or a Map with String keys or a List (a JSON config). A String is always a string config, so JSON text meant as a JSON config is given as a Map or a List. Every value is served to every context; a test that needs different values per context is written as one test per value. A blank key, a non-finite number, a value of any other type, a Map with a non-String key, or JSON contents of any other type throw a ConfigDirectorValidationException, and nothing changes. From Java, a null value is a NullPointerException.

Reads behave as they do in production: setValue("k", true) read as a String returns the in-code default value with the TYPE_MISMATCH reason, setValue("k", 2.5) read with getInt returns 2, a Long beyond the Int range reads through getDouble and gives getInt the INVALID_NUMBER reason, and setValue("k", "") serves the in-code default value with the VALUE_MISSING reason. A JSON object is encoded in the insertion order of a LinkedHashMap, which is what mapOf builds, and with sorted keys for any other map; getJsonObject returns it in org.json's order, as in production. JSON configs parse through org.json, which the artifact brings for plain JVM tests, where Android's own is a stub; on a device Android's own is used.

Controls

ControlEffect
setValue(key, value)Stores the value and, once the client is connected, delivers an update carrying only key. Reads see it when setValue returns; watches of key and ConfigsUpdated listeners see it once the Main dispatcher runs them.
removeValue(key)Removes the value and delivers a full update without it. Reads return the in-code default value with the CONFIG_STATE_MISSING reason, watches of key are handed the default, and ConfigsUpdated lists key in removedKeys.
replaceValues(values)Replaces every stored value, disarms any armed hold or failure, and delivers a full update. Use it to reset a test client shared across tests.
holdInitialization()The next initialize waits until completeInitialization() or failInitialization(), or until the client's timeoutMillis elapses.
completeInitialization()Delivers the stored values to the held initialize, so the client is ready when it returns, and lets initialize complete. Called while a hold is armed but not picked up, it disarms the hold.
failInitialization()Fails the held initialize the way an invalid SDK key does: it completes promptly, the client is not ready, and an error is logged. Called with no initialize held, it arms the next one to fail.
holdContextUpdate(), completeContextUpdate(), failContextUpdate()The same for the next updateContext.
contextUpdatesThe context of every initialize and updateContext call, in order. initialize without a context records an empty context, and resumeNetwork is not recorded.

The initialization controls act only on initialize and the context update controls only on updateContext; resumeNetwork is never held or failed. After a failed attempt, the next attempt succeeds and delivers the values stored in the meantime. A held initialize suspends its caller, so a test that acts while it is held starts it on another dispatcher, or uses the callback overload:

LoadingTest.kt
val testClient = createTestClient(values = mapOf("new-checkout" to true), timeoutMillis = 500)
testClient.holdInitialization()

val initialization = launch(Dispatchers.Default) { testClient.client.initialize() }
assertThat(testClient.client.isReady).isFalse()

testClient.completeInitialization()
initialization.join()
assertThat(testClient.client.isReady).isTrue()

Options

createTestClient takes values, timeoutMillis (the client's connection timeout, which bounds how long a held attempt waits; defaults to the SDK's production timeout of 3 seconds), and logger. The logger defaults to a StandardErrorLogger, which writes warnings and errors to standard error, because the SDK's default AndroidLogger writes to logcat, which a plain JVM test does not have; pass StandardErrorLogger(LogLevel.DEBUG) to see everything, or any ConfigDirectorLogger of your own.

What to expect

  • Under the test client, initialize publishes Ready, then ConfigsUpdated, then ContextUpdated.
  • A held attempt that times out, or that is interrupted by pauseNetwork, close, or a new attempt, leaves the client not ready until the next attempt and logs "An error occurred during initialization" (or "during context update") at error level, as production does after a connection timeout. isInitializing is false afterwards.
  • Watches are called on registration and then only when the evaluated value changes, as in production, so a setValue to the value already served calls no watch, while ConfigsUpdated still fires.
  • pausesWhileBackgrounded has no effect: the test client does not follow the app lifecycle, so backgrounding the app under Robolectric does not pause the connection. Call pauseNetwork and resumeNetwork to test that path; values set while paused are delivered on resume.
  • After close, the test client's controls are silent no-ops, and no thread or coroutine from the SDK is left running.