Mobile SDKs

Android SDK

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 two artifacts:

ArtifactContentsMinimum SDKJava bytecode
com.configdirector:configdirector-androidThe SDK218
com.configdirector:configdirector-android-composeOptional Jetpack Compose bindings2111

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 can be installed from Maven Central: https://central.sonatype.com/artifact/com.configdirector/configdirector-android

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

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

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 under your project settings in the Environments & SDK Keys tab.
  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 left out of what the SDK sends, so set both if you intend to target on them.

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 when mode is ConnectionMode.POLLING. It has no effect in any other mode. It must be positive.
    • 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.
  • 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. 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)

Every config can be read as a string, including a JSON config's raw document. 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 any other config as a string to get its raw value.

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 configdirector-android-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
// lets the SDK generate a random identifier for it.
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 lets the SDK generate a random identifier for it — note that this value segments users in percentage rollouts, so changing it can move a user into a different percentile.

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 currently initializing. It is false on creation, true after initialize is called, and false again once initialization completes. 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.

Copyright © 2026