OpenFeature Android Provider
Introduction
The OpenFeature Android Provider is intended to be used in combination with the OpenFeature Kotlin SDK in Android applications. The provider wraps the ConfigDirector Android SDK.
| Artifact | Minimum SDK | Java bytecode |
|---|---|---|
com.configdirector:openfeature-android-provider | 21 | 11 |
It requires version 0.8.0 or newer of the OpenFeature Kotlin SDK, dev.openfeature:kotlin-sdk. That SDK's provider contract is built on suspend functions and flows, so the provider is written for Kotlin; a Java application uses the Android SDK directly instead.
dev.openfeature:android-sdk, in the package dev.openfeature.sdk. The provider depends on the current artifact, dev.openfeature:kotlin-sdk, whose package is dev.openfeature.kotlin.sdk. Update your imports if you are upgrading from the old one.Config evaluation is synchronous and reads config state the provider already holds in memory, which is what makes it safe to call from a composable or from the main thread. Telemetry about those evaluations is aggregated and reported on its own coroutine, so evaluating a config never waits on the network.
Installation
The provider 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")
}
}
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
exclusiveContent {
forRepository {
maven {
name = 'ConfigDirector'
url = 'https://maven.configdirector.com'
}
}
filter {
includeGroup 'com.configdirector'
}
}
}
}
Then add the dependency to your app module:
dependencies {
implementation("com.configdirector:openfeature-android-provider:1.3.0")
}
dependencies {
implementation 'com.configdirector:openfeature-android-provider:1.3.0'
}
[versions]
configdirector-openfeature = "1.3.0"
[libraries]
openfeature-provider = { module = "com.configdirector:openfeature-android-provider", version.ref = "configdirector-openfeature" }
The OpenFeature Kotlin SDK and the ConfigDirector Android SDK are included as transitive dependencies.
The provider ships Java 11 bytecode, which the OpenFeature Kotlin SDK requires, so your application module has to compile for Java 11 or newer:
android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11
}
}
Configure and initialize the client
- Create an instance of the provider providing an Android context and your client SDK key. You can retrieve a client SDK key for each environment under
SDK keysin the dashboard's navigation panel. - Set the OpenFeature provider, along with the initial evaluation context.
- Get a client instance from OpenFeature.
import com.configdirector.openfeature.ConfigDirectorProvider
import dev.openfeature.kotlin.sdk.ImmutableContext
import dev.openfeature.kotlin.sdk.OpenFeatureAPI
// Creating the provider makes no network calls. It throws only when the SDK key is
// blank or an option holds an unusable value.
val provider = ConfigDirectorProvider(applicationContext, "YOUR-CLIENT-SDK-KEY")
// Connects and waits until config values are received, or until it times out.
// After a timeout the provider keeps trying to connect in the background.
OpenFeatureAPI.setProviderAndWait(provider, ImmutableContext(targetingKey = "user-123"))
val client = OpenFeatureAPI.getClient()
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 from it.
setProviderAndWait is a suspend function that returns once the initial config state arrives or the configured timeout elapses. When it times out, the provider's initialization fails with a ProviderNotReadyError, the OpenFeature SDK reports the Error status, and the provider keeps connecting in the background; it then emits ProviderReady as soon as config state arrives. Until then, flags resolve to their default values with the PROVIDER_NOT_READY error code.
Most applications register the provider once during startup and let it live for the lifetime of the app. That usually means doing so from the Application, with setProvider, which returns at once and initializes in the background:
import android.app.Application
import com.configdirector.openfeature.ConfigDirectorProvider
import dev.openfeature.kotlin.sdk.ImmutableContext
import dev.openfeature.kotlin.sdk.OpenFeatureAPI
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
val provider = ConfigDirectorProvider(this, "YOUR-CLIENT-SDK-KEY")
OpenFeatureAPI.setProvider(provider, initialContext = ImmutableContext(targetingKey = "user-123"))
}
}
Flag evaluations are possible once OpenFeatureAPI.getStatus() is Ready; OpenFeatureAPI.statusFlow is the same thing as a Flow, which is how a screen follows it. See reading configs in Jetpack Compose.
connection options of the Android SDK to manage that yourself instead.Additional configuration options
Additional configuration options can be passed into the provider as ClientOptions in the optional third argument of the constructor. They are the Android SDK's own options, so configuring the provider needs the SDK's types alongside it.
For example, the metadata can be provided like this:
import com.configdirector.ClientOptions
import com.configdirector.openfeature.ConfigDirectorProvider
val provider = ConfigDirectorProvider(
applicationContext,
"YOUR-CLIENT-SDK-KEY",
ClientOptions.build {
metadata("YOUR-APP-NAME", "1.0.2")
},
)
The provider accepts the same metadata, connection and logger options as the Android SDK client, refer to the additional configuration options section of the Android SDK for a full list.
Shut down
Shutting OpenFeature down, or replacing the provider, closes the provider, which closes its connection and reports any pending telemetry:
OpenFeatureAPI.shutdown()
An instance serves a single registration. After it has been shut down, create a new one rather than registering it again.
Most applications never need to do this — the provider lives as long as the process, and Android reclaims everything when the process ends. Shut it down when an application wants the connection gone before that, on sign-out for instance.
Retrieve config values
To retrieve config values, use the OpenFeature client:
val booleanValue = client.getBooleanValue("my-config-key", false)
val stringValue = client.getStringValue("my-string-config-key", "Default")
Each OpenFeature getter maps to a ConfigDirector config type:
| OpenFeature getter | ConfigDirector config value |
|---|---|
getBooleanValue | Boolean |
getStringValue | String or enum |
getIntegerValue, getDoubleValue | Number |
getObjectValue | JSON object or JSON array |
getIntegerValue takes and returns an Int, as the OpenFeature Kotlin SDK defines it; a value written as a decimal is truncated. getStringValue reads a string, enum, URL, or custom config, and a JSON config as its raw document; a boolean, integer, or float config resolves to the default value with the TYPE_MISMATCH error code.
For getObjectValue, the default value decides the type the config is read as: a Value.Structure reads a JSON config as a structure, a Value.List reads one as a list, and a Value.String, Value.Integer, Value.Double or Value.Boolean reads the config as that type. A Value.Null or Value.Instant default names no type to read, so it resolves to itself with the TYPE_MISMATCH error code.
import dev.openfeature.kotlin.sdk.Value
val settings = client.getObjectValue("my-json-config-key", Value.Structure(emptyMap()))
val theme = settings.asStructure()?.get("theme")?.asString()
For additional information regarding the OpenFeature client refer to the OpenFeature Kotlin SDK documentation.
Evaluation details
The detailed getters of the OpenFeature client, such as getBooleanDetails, report why an evaluation produced the value that it did:
| Outcome | Reason | Error code |
|---|---|---|
| A value was found | TARGETING_MATCH | |
| The config carries no value | DEFAULT | |
| The config key is unknown | ERROR | FLAG_NOT_FOUND |
| No config state has arrived yet | ERROR | PROVIDER_NOT_READY |
| The value does not match the requested type | ERROR | TYPE_MISMATCH |
When a value was found, the variant is ConfigDirector's identifier for that value. In every other case the default value is returned.
val details = client.getBooleanDetails("my-config-key", false)
Log.i("MyApp", "my-config-key is ${details.value} because ${details.reason} (variant ${details.variant})")
Reading configs in Jetpack Compose
The getters are synchronous and make no network calls, so they are safe to call directly from a composable. That gives you the current value, but the composable will not recompose on its own when the value changes.
To have a composable follow a config, re-read it whenever the SDK status moves or the provider reports a configuration change. OpenFeatureAPI.statusFlow carries the status, and OpenFeatureAPI.observe() is a Flow of the provider's events:
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dev.openfeature.kotlin.sdk.OpenFeatureAPI
import dev.openfeature.kotlin.sdk.OpenFeatureStatus
import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents
@Composable
fun DarkModeToggle() {
val status by OpenFeatureAPI.statusFlow.collectAsState(initial = OpenFeatureStatus.NotReady)
var generation by remember { mutableIntStateOf(0) }
// Counts every configuration change, so the read below runs again after each one.
LaunchedEffect(Unit) {
OpenFeatureAPI.observe<OpenFeatureProviderEvents.ProviderConfigurationChanged>()
.collect { generation++ }
}
// Re-read when the status moves -- to Ready once config state arrives, for instance --
// and after every configuration change.
val darkMode = remember(status, generation) {
OpenFeatureAPI.getClient().getBooleanValue("dark-mode", false)
}
Switch(checked = darkMode, onCheckedChange = null)
}
Until the status is Ready, the read returns the default value, which is a good moment to show a loading state rather than flashing the in-code default on a slow connection.
Update the user context
import dev.openfeature.kotlin.sdk.ImmutableContext
import dev.openfeature.kotlin.sdk.OpenFeatureAPI
import dev.openfeature.kotlin.sdk.Value
OpenFeatureAPI.setEvaluationContextAndWait(
ImmutableContext(
targetingKey = "12345", // In OpenFeature, the targeting key represents the context's user ID
attributes = mapOf(
"name" to Value.String("Example User"),
// Any arbitrary traits which can be referenced in targeting rules
"traits" to Value.Structure(mapOf("region" to Value.String("North America"))),
),
),
)
The evaluation context maps onto the ConfigDirector user context as follows:
| OpenFeature evaluation context | ConfigDirector user context |
|---|---|
The targeting key, or otherwise an id attribute | id |
The name attribute | name |
The traits structure attribute | traits |
The boolean anonymous attribute | anonymous |
Any other attribute is ignored. Put the values your targeting rules depend on inside traits. Traits are JSON-shaped values; a context the SDK could not send, such as one holding a trait that is not a finite number, is rejected with an InvalidContextError.
Reconciling status and config values continue to evaluate to the currently cached values from the prior user context.Awaiting
setEvaluationContextAndWait will wait until the new config values are downloaded or the connection times out. In the case of a timeout, the provider reports the Error status and continues to attempt to connect with the new context in the background, then emits ProviderReady once it succeeds.setEvaluationContext, without the wait, returns at once and reconciles in the background; follow OpenFeatureAPI.statusFlow to know when the new values are in effect.
For additional information regarding the OpenFeature client refer to the OpenFeature Kotlin SDK documentation.
Events
The provider publishes its events through OpenFeatureAPI.observe(), a Flow filtered by event type. It emits ProviderConfigurationChanged whenever configs are updated on the dashboard or via the admin API, carrying the keys of the configs in the update:
import dev.openfeature.kotlin.sdk.OpenFeatureAPI
import dev.openfeature.kotlin.sdk.events.OpenFeatureProviderEvents
lifecycleScope.launch {
OpenFeatureAPI.observe<OpenFeatureProviderEvents.ProviderConfigurationChanged>().collect { event ->
Log.i("MyApp", "Configs updated: ${event.eventDetails?.flagsChanged}")
}
}
lifecycleScope.launch {
OpenFeatureAPI.statusFlow.collect { status ->
Log.i("MyApp", "OpenFeature status: $status")
}
}
It emits ProviderReady when config state arrives after initialization or a context change has already timed out, which moves the status from Error back to Ready.
Test your code
Code that reads flags through OpenFeature is tested by swapping the provider, so it never has to change for a test. The OpenFeature Kotlin SDK ships no in-memory provider yet, so this provider accepts a test client from the Android SDK's testing tools, com.configdirector:android-sdk-testing, instead: the SDK's real client connected to an in-memory server your test controls. The provider then resolves every flag from the values the test sets, opens no network connection, sends no telemetry, and needs no Android Context, so the test runs in a plain JVM unit test, under Robolectric, or in an instrumented test alike.
The testing tools must be the same version as the android-sdk on your test classpath, which the provider brings transitively. Add them in test scope, with kotlinx-coroutines-test for the Main dispatcher a plain JVM test lacks:
dependencies {
testImplementation("com.configdirector:android-sdk-testing:1.7.0")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
}
dependencies {
testImplementation 'com.configdirector:android-sdk-testing:1.7.0'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0'
}
The constructor that takes a client sits behind the @ConfigDirectorProviderTestingApi opt-in, which keeps it out of application code, so the test opts in:
import com.configdirector.openfeature.ConfigDirectorProvider
import com.configdirector.openfeature.ConfigDirectorProviderTestingApi
import com.configdirector.testing.createTestClient
import dev.openfeature.kotlin.sdk.ImmutableContext
import dev.openfeature.kotlin.sdk.OpenFeatureAPI
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
@OptIn(ConfigDirectorProviderTestingApi::class)
class CheckoutTest {
@Before
fun setUpMainDispatcher() = Dispatchers.setMain(UnconfinedTestDispatcher())
@After
fun tearDown() = runBlocking {
OpenFeatureAPI.shutdown()
Dispatchers.resetMain()
}
@Test
fun `shows the new checkout`() = runBlocking {
val testClient = createTestClient(values = mapOf("new-checkout" to true, "max-items" to 20))
OpenFeatureAPI.setProviderAndWait(
ConfigDirectorProvider(testClient.client),
ImmutableContext(targetingKey = "user-123"),
)
val client = OpenFeatureAPI.getClient()
assertThat(client.getBooleanValue("new-checkout", false)).isTrue()
assertThat(client.getIntegerValue("max-items", 0)).isEqualTo(20)
testClient.setValue("new-checkout", false)
assertThat(client.getBooleanValue("new-checkout", false)).isFalse()
testClient.client.close()
}
}
The provider hands its events to OpenFeature through the client's events flow, which runs on the Main dispatcher. Robolectric and instrumented tests have one; a plain JVM test sets one from kotlinx-coroutines-test as above, and without it the first call that needs one fails with an IllegalStateException saying that Dispatchers.Main is missing. OpenFeatureAPI is one global, so tests that register their own provider on it cannot run in parallel; shut it down after each test, as above, so the next one starts from a clean slate.
The test client's controls are the same as with the Android SDK: setValue, removeValue, replaceValues, the initialization and context update holds and failures, and contextUpdates. The Android SDK's testing section describes each of them and the values they take. Through the provider:
setValuemakes the OpenFeature client resolve the new value at once, with theTARGETING_MATCHreason, and the provider emitProviderConfigurationChangedwith that key;removeValuemakes the flag resolve to its default value with theFLAG_NOT_FOUNDerror code.- The provider initializes the client when it is registered and updates the client's context whenever the evaluation context changes, as it does with a client it creates itself. A test client that was already initialized therefore reconnects on registration, and its
contextUpdatesrecords the provider's context after the test's own. holdInitialization()before registering keepssetProviderAndWaitwaiting, with the statusNotReadyand flags at their default values with thePROVIDER_NOT_READYerror code, untilcompleteInitialization()or the test client'stimeoutMilliselapses. A timed-out or failed initialization (failInitialization()) fails the provider's initialization with aProviderNotReadyError, so OpenFeature reports theErrorstatus, as a connection failure would.- The provider never closes a client it was given, whether it is shut down explicitly, replaced with
setProvider, or shut down withOpenFeatureAPI.shutdown(). Close the test client'sclientwhen the test is done, as in the example.