Android SDK
Read flags in Android apps with the Kotlin SDK, which evaluates them on the device and keeps working offline.
The Kotlin SDK fetches your environment’s client flags and evaluates them on the device, so your users’ attributes never leave the app. It starts from the last flags it saw, even offline, polls only while the app is in the foreground, and is pure Kotlin, with no native code.
Install
From Maven Central, for Android apps with minSdk 24:
// app/build.gradle.kts
dependencies {
implementation("io.featflags.kotlin:featflags-android:0.1.0")
implementation("io.featflags.kotlin:featflags-openfeature:0.1.0") // with the OpenFeature provider
implementation("io.featflags.kotlin:featflags-otel:0.1.0") // with the OpenTelemetry hook
}
| Artifact | What |
|---|---|
featflags-android |
The client wired to Android: the last flags in the app’s cache, foreground and background from the process lifecycle, a refetch when the network comes back. |
featflags-core |
The client itself, plain Kotlin on the JVM (kotlinx.serialization and kotlinx.coroutines only). featflags-android brings it in. |
featflags-openfeature |
A provider for the OpenFeature Kotlin SDK. |
featflags-otel |
An OpenTelemetry hook. |
The library’s manifest adds the INTERNET and ACCESS_NETWORK_STATE permissions. It uses no reflection, so R8 needs no keep rules.
Create a client
Create one client in Application.onCreate and keep it for the life of the process:
class App : Application() {
lateinit var flags: FeatflagsClient
override fun onCreate() {
super.onCreate()
flags = FeatflagsClient.android(
this,
FeatflagsOptions(
key = "pk_…",
cdnUrl = "https://flags.featflags.io",
eventsUrl = "https://events.featflags.io", // evaluation counts and experiments
),
)
}
}
Building the client reads the last flags the app saw, one small file; network and disk work happen on Dispatchers.IO.
Client keys only
Use the environment’s client key (pk_…). It reaches the flags you mark client-visible (and the flags they need as prerequisites), with target keys hashed.
The client refuses a server key (sk_…): FeatflagsOptions throws an IllegalArgumentException. A server key reaches every flag, server-only ones included, and the secret that unlocks private segments for any user, and anything in an app is readable by whoever unpacks it. Keep server keys on your servers, with a server SDK.
An environment’s allowed origins don’t apply to apps: a native app has no origin, so the client ignores the list.
Options
FeatflagsOptions |
Default | What |
|---|---|---|
key |
required | The client key, pk_…. |
cdnUrl |
required | https://flags.featflags.io. |
eventsUrl |
none | https://events.featflags.io. Evaluation counts, experiment exposures and track events are off without it. |
context |
anonymous user | The context until the first identify. |
membershipTokens |
none | Private segment tokens for context; see Lists and private segments. |
experimentSeeds |
none | Experiment seeds for context, from your backend. |
experimentKey |
none | The environment’s experiment key, xk_…, for anonymous users; public once in an app. |
bootstrap |
none | A Document.parse(…), such as one bundled with the app, to evaluate before the first fetch. |
cacheDir |
the app’s cacheDir |
Where the last flags are kept. |
dataDir |
the app’s noBackupFilesDir |
Where the anonymous key is kept. |
readyTimeout |
2 s | How long waitForReady waits. |
pollInterval |
60 s | Time between checks for changes in the foreground, ±10%. Duration.ZERO turns polling off. |
flushInterval |
60 s | Time between reports. The first waits up to one extra interval at random. |
timeout |
10 s | HTTP connect and read timeout. |
onExposure |
none | Called the first time each (context key, flag, variant) is evaluated, for your own analytics. |
hooks |
none | See Hooks. |
appState |
AndroidAppState |
Foreground, background and network events. |
clock |
System.currentTimeMillis |
Epoch milliseconds for rollout steps and date conditions. |
AndroidAppState(context, lifecycleOwner) takes another LifecycleOwner than the process’s if your app’s idea of foreground is narrower, such as one activity.
Evaluate flags
if (flags.bool("new-checkout", false)) { … }
val d = flags.stringDetail("pricing-page", "v1")
Log.i("flags", "pricing-page: ${d.value} (${d.reason}, rule ${d.ruleId})")
bool, string, number (a Double) and json (a kotlinx.serialization JsonElement) return the flag’s value, or the fallback when the flag is missing, the context is invalid or the value has another type. Their …Detail variants (boolDetail, stringDetail, numberDetail, detail) return a Detail<T> with value, variant and the reason: Reason.RULE with ruleId, Reason.PREREQUISITE_FAILED with prerequisite; reason.wire is its name as other SDKs spell it. Each takes an optional context last, to evaluate another context than the identified one.
Evaluation is synchronous, never blocks and never throws, so it’s safe on the main thread. Before the first fetch answers it uses the flags the last run kept (cached is true), else bootstrap, else the fallbacks with reason not_found.
Contexts and identify
The client evaluates one context at a time: options.context until the first identify. Build a context with Context.user, Context.of or Context.fromJson:
Context.user("u-alice", mapOf("email" to "alice@acme.com")) // a user, the default kind
Context.of(mapOf("kind" to "device", "key" to deviceId, "os" to "android")) // another kind
Context.of(mapOf("kind" to "multi", "user" to mapOf("key" to "u-alice"), "org" to mapOf("key" to "acme", "plan" to "team")))
Attribute values are strings, numbers, booleans, JsonElements, or lists of those; null, maps, nested lists, NaN and infinities count as missing. Pass dates as RFC 3339 strings or epoch milliseconds. An invalid context gets the fallback with Reason.ERROR.
After sign-in, from a coroutine:
flags.identify(Context.user(userId, mapOf("plan" to "pro", "app_version" to BuildConfig.VERSION_NAME)))
identify is a suspend function: it switches the context at once, notifies listeners, and returns once the context’s lists have loaded (below). waitForReady() returns after the first fetch and the initial context’s lists, or after readyTimeout.
A user without a key gets the device’s anonymous key, so a signed-out user stays in the same rollout buckets across launches. It’s a random UUID in the app’s noBackupFilesDir, so a backup restored on another device doesn’t share it. Without options, the client starts as that anonymous user.
Lists and private segments
Targets and segment lists of up to 64 keys travel with the flags, hashed. Longer ones, up to millions of keys, are split into small files: identify fetches the context’s part, one request per context kind. Until they arrive, list lookups don’t match and evaluations that reach them report Reason.MEMBERSHIP_PENDING.
Private segments can only be read with a token for the user. Your backend computes it with a server SDK (membershipToken in Java and PHP, membership_token in Python, Ruby and Rust, MembershipToken in Go and .NET) and hands it to the app at sign-in:
flags.identify(Context.user(userId), membershipTokens = mapOf("user" to tokenFromBackend))
A token applies to the context it was passed with only. How lists work.
Updates, offline and the app’s lifecycle
- Polling happens in the foreground only, every minute ±10%. Coming back to the foreground, or the network returning in the foreground, fetches at once, at most every 5 seconds. The client never goes back to older flags, and keeps the last good ones when a fetch fails.
- Offline: every new version of your flags is written to
cacheDir/featflags/, and the next cold start evaluates it before the network answers, offline included. Android may clearcacheDir; the next start then fetches. Abootstrapnewer than what’s kept wins. - Reports: counts of which variant each flag served, never contexts, sent every minute and when the app goes to the background. What SDKs send.
Evaluations change when new flags arrive, the context changes, or its lists load. subscribe(listener) calls listener then, often on the client’s background thread, and returns a function that unsubscribes. For UI, observe { … } is a Flow of an evaluation’s distinct results and state(scope) { … } the same as a StateFlow, both evaluated where they’re collected:
class CheckoutViewModel(flags: FeatflagsClient) : ViewModel() {
val newCheckout: StateFlow<Boolean> = flags.state(viewModelScope) { bool("new-checkout", false) }
}
// Compose
val newCheckout by flags.observe { bool("new-checkout", false) }.collectAsState(initial = false)
Logs go through java.util.logging, which logcat shows under the tag io.featflags.
Experiments
When an environment runs experiments, the client reports which variant each user saw and the events you track for the experiments’ metrics:
flags.identify(Context.user(userId), membershipTokens = tokens, experimentSeeds = seeds)
val on = flags.bool("new-checkout", false) // reported, if a running experiment's audience served it
flags.track("checkout.completed", order.total) // [a-z0-9_.:-]{1,64}, an optional number; nothing else
flags.track("page.error")
track is for the identified context. A bad event key or value is logged once and dropped, never thrown. An event no running experiment counts sends nothing, so tracking freely costs nothing.
Users are reported under IDs made with the environment’s experiment key, which featflags never holds. The app shouldn’t hold it either:
- Signed-in users: your backend computes the user’s seeds with a server SDK (
experimentSeeds(context)in Java and PHP,experiment_seedsin Python, Ruby, Rust and Elixir,ExperimentSeedsin Go and .NET) and hands them over at sign-in with the membership tokens.ExperimentSeeds.fromJson(json)reads the{"fingerprint", "seeds"}object they produce. Seeds apply to the context they were passed with only, and seeds made with a key you’ve since rotated send nothing, logged once, until the app gets new ones. - Anonymous users:
experimentKey = "xk_…"computes IDs on the device. The key is then public, so use it only for experiments on anonymous traffic.
Exposures and events are sent with the counts: every minute, when the app goes to the background, on close(), and as soon as 5,000 are waiting. Each carries how long ago it happened, so events recorded offline are dated right. They aren’t kept across launches, and past 50,000 waiting, more are dropped. track runs no hooks.
Hooks
Hooks run around every evaluation and every identify. Implement io.featflags.kotlin.Hook, overriding the stages you need, and pass it in hooks:
class Timing : Hook {
override val name = "timing"
override fun beforeEvaluation(series: EvaluationSeries, data: Map<String, Any?>) = data + ("start" to System.nanoTime())
override fun afterEvaluation(series: EvaluationSeries, data: Map<String, Any?>, detail: Detail<Any?>): Map<String, Any?> {
Log.d("flags", "${series.flagKey} ${detail.reason} ${(System.nanoTime() - data["start"] as Long) / 1000} µs")
return data
}
}
beforeEvaluation stages run in registration order and afterEvaluation stages in reverse, on the evaluating thread; each hook’s after stage gets what its own before stage returned. beforeIdentify and afterIdentify do the same around identify, with IdentifyResult.COMPLETED, or ERROR when the client was closed first. A stage that throws is logged and skipped. The hooks contract.
OpenTelemetry
featflags-otel has OtelHook(), which adds a feature_flag.evaluation event with OpenTelemetry’s feature flag attributes to the current span:
FeatflagsOptions(key = "pk_…", cdnUrl = "https://flags.featflags.io", hooks = listOf(OtelHook()))
OpenFeature
featflags-openfeature is a provider for the OpenFeature Kotlin SDK (0.9). The OpenFeature context is the client’s, and setting it identifies:
OpenFeatureAPI.setProviderAndWait(FeatflagsProvider(flags), initialContext = ImmutableContext(targetingKey = userId))
val client = OpenFeatureAPI.getClient()
client.getBooleanValue("new-checkout", false)
OpenFeatureAPI.setEvaluationContextAndWait(ImmutableContext(targetingKey = otherUserId)) // identify
You own the FeatflagsClient; the provider’s shutdown leaves it alone. Without a kind attribute, an evaluation context is a user whose key is the targeting key; kind: "multi" makes each structure attribute the member of the kind it’s named after. New flags are a ProviderConfigurationChanged event. How reasons map to OpenFeature’s.
Shut down
flush() sends counts, exposures and events now, and throws IOException if it can’t. close() stops polling and listening and flushes. Both are suspend functions.