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 clear cacheDir; the next start then fetches. A bootstrap newer 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_seeds in Python, Ruby, Rust and Elixir, ExperimentSeeds in 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.