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:
| Artifact | Contents | Minimum SDK | Java bytecode |
|---|---|---|---|
com.configdirector:configdirector-android | The SDK | 21 | 8 |
com.configdirector:configdirector-android-compose | Optional Jetpack Compose bindings | 21 | 11 |
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")
}
dependencies {
implementation 'com.configdirector:configdirector-android:1.2.0'
// Optional, for Jetpack Compose applications
implementation 'com.configdirector:configdirector-android-compose:1.2.0'
}
[versions]
configdirector = "1.2.0"
[libraries]
configdirector-android = { module = "com.configdirector:configdirector-android", version.ref = "configdirector" }
# Optional, for Jetpack Compose applications
configdirector-android-compose = { module = "com.configdirector:configdirector-android-compose", version.ref = "configdirector" }
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
- 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 Keystab. - 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()
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.
ConfigDirectorClient client = new ConfigDirectorClient(getApplicationContext(), "YOUR-CLIENT-SDK-KEY");
// Connects, and calls back on the main thread once config values are received or
// the attempt times out. After a timeout the client keeps trying in the background.
client.initialize(null, () -> Log.i("MyApp", "ConfigDirector ready: " + client.isReady()));
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.
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") })
}
}
}
import android.app.Application;
import com.configdirector.ConfigDirectorClient;
import com.configdirector.ConfigDirectorContext;
public class MyApplication extends Application {
private ConfigDirectorClient client;
@Override
public void onCreate() {
super.onCreate();
client = new ConfigDirectorClient(this, "YOUR-CLIENT-SDK-KEY");
client.initialize(
ConfigDirectorContext.builder().id("user-123").build(),
() -> Log.i("MyApp", "ConfigDirector ready: " + client.isReady()));
}
public ConfigDirectorClient getClient() {
return client;
}
}
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")
},
)
ConfigDirectorClient client = new ConfigDirectorClient(
getApplicationContext(),
"YOUR-CLIENT-SDK-KEY",
ClientOptions.builder()
.metadata(new Metadata("YOUR-APP-NAME", "1.0.2"))
.build());
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)) },
)
ConfigDirectorClient client = new ConfigDirectorClient(
getApplicationContext(),
"YOUR-CLIENT-SDK-KEY",
ClientOptions.builder().logger(new AndroidLogger(LogLevel.DEBUG)).build());
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()) },
)
import com.configdirector.ConfigDirectorLogger;
import com.configdirector.LogLevel;
public final class MyLogger implements ConfigDirectorLogger {
// Messages more verbose than this level are never passed to `log`.
@Override
public LogLevel getLevel() {
return LogLevel.INFO;
}
@Override
public void log(LogLevel level, String message, Throwable error) {
// your specific logging library implementation here
}
}
connection
ConnectionOptions accepts five optional values:
mode- The connection mode, which can be
ConnectionMode.STREAMINGorConnectionMode.POLLING. It is recommended to use the default ofSTREAMINGunless you have a specific need to usePOLLINGinstead. STREAMINGholds a connection open and receives changes as they happen, reconnecting on its own with a backoff.- Defaults to
ConnectionMode.STREAMING
- The connection mode, which can be
pollingIntervalMillis- How often to re-fetch config state when
modeisConnectionMode.POLLING. It has no effect in any other mode. It must be positive. - Defaults to
60_000(60 seconds)
- How often to re-fetch config state when
timeoutMillis- The timeout to be used in initialization and when updating the context. This is how long
initializewill wait for data from ConfigDirector services before returning. If the timeout is reached,initializewill 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
initializecall. - Defaults to
3_000(3 seconds)
- The timeout to be used in initialization and when updating the context. This is how long
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
falseto manage the connection yourself withpauseNetwork()andresumeNetwork(). - Defaults to
true
- 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
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)
}
},
)
ConfigDirectorClient client = new ConfigDirectorClient(
getApplicationContext(),
"YOUR-CLIENT-SDK-KEY",
ClientOptions.builder()
.connection(
ConnectionOptions.builder()
.mode(ConnectionMode.POLLING)
.pollingIntervalMillis(600_000L)
.timeoutMillis(2_000L)
.build())
.build());
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}")
}
}
}
import com.configdirector.ClientEvent;
import com.configdirector.Subscription;
Subscription subscription = client.addEventListener(event -> {
if (event instanceof ClientEvent.Ready) {
Log.i("MyApp", "Client is ready after " + ((ClientEvent.Ready) event).getReason());
} else if (event instanceof ClientEvent.ConfigsUpdated) {
Log.i("MyApp", "Configs updated: " + ((ClientEvent.ConfigsUpdated) event).getKeys());
}
});
// Close the subscription to stop listening
subscription.close();
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- AConnectReason, the action that triggered the client to connect/reconnect, which can beConnectReason.INITIALIZATION,ConnectReason.CONTEXT_UPDATE, orConnectReason.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- AList<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- TheConfigDirectorContextthe client is now evaluating against. It can benull.
- Emitted whenever the user context takes effect, including during initialization. It is emitted before
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 aBoolean,String,IntegerorDouble, 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 thevalue. This can be useful for large values, like a JSON config, or to avoid disclosing the values themselves to third parties. It isnullif 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 beFOUND_MATCH. If the evaluation had to fall back to the default value, thereasonwill 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.
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}") }
}
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")
}
}
import com.configdirector.Subscription;
// Retrieve the current value. There is a getter per type a config can be read
// as, and each returns the default value until the client is ready.
boolean darkMode = client.getBoolean("my-config-key", false);
// Subscribe to value updates. The listener is called on the main thread with the
// current value, and then every time the evaluated value changes.
Subscription subscription = client.watchBoolean(
"my-config-key",
false,
value -> Log.i("MyApp", "Value updated: " + value));
// Close the subscription to stop watching
subscription.close();
import com.configdirector.compose.configValue
@Composable
fun SampleScreen() {
// Recomposes whenever the evaluated value changes.
val darkMode = configValue("my-config-key", false)
Text("my-config-key: $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.
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"))
Map<String, Object> theme = client.getJsonObject("theme", Map.of("primary", "#101010"));
String primary = (String) theme.get("primary");
List<Object> regions = client.getJsonArray("regions", List.of("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:
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:
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.
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:
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")
},
)
import com.configdirector.ConfigDirectorClient;
import com.configdirector.ConfigDirectorContext;
ConfigDirectorClient client = new ConfigDirectorClient(getApplicationContext(), "YOUR-CLIENT-SDK-KEY");
ConfigDirectorContext context = ConfigDirectorContext.builder()
.id("12345")
.name("Example User")
// Any arbitrary traits which can be referenced in targeting rules
.trait("region", "North America")
.build();
client.initialize(context, () -> Log.i("MyApp", "ConfigDirector ready: " + client.isReady()));
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) })
import com.configdirector.ConfigDirectorContext;
client.updateContext(
ConfigDirectorContext.builder()
.id("654321")
.name("Another User")
.trait("region", "Australia")
.build(),
() -> Log.i("MyApp", "Context updated"));
// 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.builder().anonymous(true).build(),
() -> Log.i("MyApp", "Signed out"));
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.
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.