Skip to content

Android SDK Reference

TL;DR: Add io.featureflip:featureflip-android, call client.boolVariation("flag-key", false), and you’re evaluating flags from any Android app — Kotlin-first, lifecycle-aware.

The Featureflip Android SDK is a Kotlin library for evaluating feature flags in Android apps. Like the other client SDKs, it gets flag values already evaluated by the Featureflip Evaluation API, so your targeting rules never ship inside your APK or AAB. It is published to Maven Central as io.featureflip:featureflip-android and works on Android API 26 and up. Nothing in it depends on the Android framework at compile time, so the same artifact runs on a plain JVM.

Flag reads are synchronous. boolVariation and the other typed methods return straight from an in-memory snapshot, so you can call them on the main thread, from a composable or from a ViewModel without launching a coroutine, and a screen sees a changed value the next time it reads the flag. Only initialize() blocks. It waits up to initTimeoutMs for the first fetch, so run it off the main thread. After that a server-sent events stream keeps the snapshot current, or polling does if you set streaming = false. With androidx.lifecycle:lifecycle-process on the classpath the SDK closes the stream when your app goes to the background and reopens it on return.

The last flag values the SDK received are written to a file in the JVM temp directory, which on Android is your app’s cache directory, and initialize() reads that file before it touches the network. Open the app in airplane mode and targeting rules resolve to the values from the last successful sync while the stream keeps retrying in the background. For the same context, an Android client gets the same value as a JVM backend running the Java SDK: the Evaluation API and the Java SDK are both tested against one shared set of test vectors.

View source on GitHub

Add the dependency to your build.gradle.kts:

dependencies {
implementation("io.featureflip:featureflip-android:3.4.0")
}

Apps that shrink their code with R8 or ProGuard need no keep rules for the SDK. Since 3.3.0 the library brings its own, and both tools apply them automatically.

The FeatureflipConfig data class accepts the following options:

OptionTypeDefaultDescription
clientKeyStringrequiredClient SDK key from your project settings
baseUrlString"https://eval.featureflip.io"Evaluation API base URL
contextMap<String, Any?>emptyMap()Initial evaluation context (user attributes)
streamingBooleantrueEnable SSE for real-time flag updates
pollIntervalMsLong30_000Polling interval in milliseconds (used when streaming is disabled)
flushIntervalMsLong30_000Interval in milliseconds between event flush batches
flushBatchSizeInt100Maximum number of events per flush batch
initTimeoutMsLong10_000Maximum time in milliseconds to wait for initial flag fetch
applicationContextAny?nullYour Android Context. When set, the SDK stores the user_id it generates for an anonymous user in SharedPreferences, so that user stays in the same rollout bucket across app restarts. Without it the id lasts only until the process exits
inspectorsList<EvaluationInspector>emptyList()Callbacks run on every variation call. See Evaluation inspectors
sendEvaluationEventsBooleantrueReport which flags the app reads, so flags it no longer reads can be archived. See Read reporting
wrapperNameString?nullFor libraries that wrap the SDK: a name appended to the User-Agent after the SDK’s own token
wrapperVersionString?nullVersion sent with wrapperName
val config = FeatureflipConfig(
clientKey = "your-client-sdk-key",
context = mapOf("user_id" to "u-123"),
streaming = true,
pollIntervalMs = 60_000,
initTimeoutMs = 15_000,
)

Obtain a client via the static factory FeatureflipClient.get(config). There is no public constructor — the factory dedupes by client key, so every call with the same key returns a handle pointing at one shared underlying client. See Lifetime for the full contract.

import dev.featureflip.android.FeatureflipClient
import dev.featureflip.android.FeatureflipConfig
val config = FeatureflipConfig(clientKey = "your-client-sdk-key")
val client = FeatureflipClient.get(config)
client.initialize()

initialize() loads any cached flags from disk, makes a synchronous fetch for fresh flag values from the API, then starts SSE streaming (or polling) and the event processor. The SDK is usable with cached values even if the network request fails. Call initialize() from a background thread to avoid blocking the main thread.

Access from anywhere:

val enabled = FeatureflipClient.get(config).boolVariation("my-feature", false)

Calling get() again with the same clientKey returns a new handle pointing at the cached shared core — no new HTTP request, no new SSE connection. Only the first call does the real work.

The Android SDK is singleton-by-construction. You cannot new a client directly, and every FeatureflipClient.get(config) call with the same clientKey returns a handle pointing at one shared background client. This is intentional — a Featureflip client maintains a long-lived SSE streaming connection, a periodic event flush loop, a disk cache, and a lifecycle observer. Opening more than one per client key per process wastes battery, bandwidth, and causes flag updates to fan out unpredictably.

The factory refcounts the shared core using AtomicInteger:

  • The first get() for a given client key constructs the shared core and returns a handle with refcount 1.
  • Subsequent get() calls with the same key return a new handle and atomically increment the refcount. The config argument on repeat calls is ignored; if the baseUrl / streaming / intervals differ meaningfully, a warning is logged.
  • handle.close() decrements the refcount. When the refcount reaches zero, the shared core runs its real shutdown: stops streaming/polling, removes the lifecycle observer, flushes pending events, and removes itself from the factory cache.
  • Double-closing the same handle is a no-op — the handle tracks its own disposed state via AtomicBoolean and will not double-decrement the refcount.
  • A different client key always constructs an independent shared core.

This makes the SDK safe under any lifetime pattern:

  • Application-level singleton (the recommended pattern): call get() once in your Application.onCreate(), inject the handle everywhere, close() it in onTerminate() (if you care about clean shutdown in tests).
  • Per-Activity or per-ViewModel access: calling get() in each Activity’s onCreate() or each ViewModel constructor is harmless. Each call returns a fresh handle on the same underlying shared core. Close the handle in the corresponding onDestroy() / ViewModel onCleared() to decrement the refcount; the core stays alive as long as at least one handle references it.
  • DI container misregistration: Hilt/Koin/Dagger scoped or transient bindings become harmless. Each resolution allocates a handle, not a new HTTP client or SSE connection.

Use FeatureflipClient.forTesting(overrides) to obtain a standalone test client that is not registered in the factory cache — each call returns an independent instance with no background connections.

client.isInitialized: Boolean

A read-only property that returns true once initialize() has completed (regardless of whether the network fetch succeeded — cached flags may be in use), and false after close() — a closed handle serves the caller’s default from every accessor.

All variation methods are synchronous and return immediately from an in-memory snapshot. They accept a flag key and a default value. The default is returned if the flag is missing or has an unexpected type.

Since this is a client-side SDK, evaluation context is set at initialization or via identify() — it is not passed per evaluation call.

val enabled = client.boolVariation("dark-mode", false)
val color = client.stringVariation("button-color", "blue")
val limit = client.numberVariation("rate-limit", 100.0)

Returns a Double.

val config = client.jsonVariation("ui-config", null)

Returns Any? — the raw deserialized value.

val detail = client.flagDetail("new-checkout")
if (detail?.reason == "prerequisite-failed") {
Log.d("flags", "blocked by ${detail.prerequisiteKey}")
}

Returns the whole FlagValue the server sent for that key, or null if the app has no value for it. Use it when you need to know why a user got a value as well as what the value is.

val config = FeatureflipConfig(
clientKey = "your-client-sdk-key",
inspectors = listOf { event ->
Log.d("flags", "${event.flagKey} -> ${event.value} (${event.reason})")
},
)

An inspector is a function that receives an EvaluationEvent each time a typed variation method runs, after the value has been coerced to the type you asked for. The event carries flagKey, context, value, variationKey, reason, ruleId (parsed out of a rule-match: reason), prerequisiteKey and an ISO-8601 timestamp. A key the app has no value for arrives with reason flag-not-found and a null variationKey. An inspector that throws is caught and logged, and the remaining inspectors still run. flagDetail doesn’t call inspectors.

Call identify() to re-evaluate all flags for a new user context — typically after login. This makes a network request to the evaluation API with the new context and updates all cached flag values.

client.identify(mapOf("user_id" to "u-123", "plan" to "pro"))

The streaming or polling data source is also updated with the new context.

Enqueues a custom analytics event. Events are batched and flushed automatically.

client.track("purchase-completed", mapOf(
"plan" to "pro",
"amount" to 29.99,
))

Featureflip counts each event name, per environment. The metadata you pass is transmitted with the event but is not stored, and event counts are not surfaced in the app or API.

Starts sending all buffered events immediately and returns.

Sending them is a network round-trip, so it runs on a background dispatcher. Calling this from the main thread is safe.

client.flush()

Suspending version of flush(). Returns once the send has been attempted, so use it when you need the events actually delivered before moving on.

client.flushAndAwait()

Since 3.3.0, the SDK reports the flags your app reads, which is how Featureflip tells a client-side flag still in use from one whose code is gone. The typed variation methods and flagDetail count as reads, and so does reading a key the app was never sent. Each flag, variation and user is reported about once an hour per device and again whenever the app returns to the foreground, in the same batches as track() events. A repeat read inside that hour costs no allocation and no network call.

To opt out, set sendEvaluationEvents = false. Featureflip then counts every flag it sends the app as read, so none of them can be archived while the app is in use.

Decrements the refcount on the shared core. When the last handle for a given client key is closed, the shared core stops streaming/polling, removes the lifecycle observer, flushes remaining events, and removes itself from the factory cache. Double-close on the same handle is a no-op.

Safe to call from the main thread. The final flush runs on a background dispatcher, so this returns before it completes.

client.close()

Suspending version of close(). Returns once the final flush has been attempted. Reach for it from a scope that is still alive and where the buffered events matter, such as a sign-out handler or an application-scoped coroutine.

Do not reach for it in ViewModel.onCleared(). viewModelScope is already cancelled by then, so viewModelScope.launch { } there never runs its body at all, and onCleared() is not a suspend context either. Call plain close() from onCleared().

client.closeAndAwait()

On Android with androidx.lifecycle:lifecycle-process on the classpath, the SDK automatically observes app lifecycle events:

  • Background: Stops streaming/polling and flushes events
  • Foreground: Resumes streaming/polling

No manual intervention is required.

Creates a test client with static flag overrides. No network calls are made and no background tasks are started. The client is immediately initialized.

val client = FeatureflipClient.forTesting(mapOf(
"dark-mode" to true,
"pricing-tier" to "enterprise",
"rate-limit" to 500,
))
client.boolVariation("dark-mode", false) // true
client.stringVariation("pricing-tier", "free") // "enterprise"
client.boolVariation("unknown", false) // false (default)

Signature:

fun forTesting(overrides: Map<String, Any?>): FeatureflipClient

Supported override value types: Boolean, String, Int, Double.

One flag as the server sent it. flagDetail returns it:

PropertyTypeDescription
valueAny?The evaluated flag value
variationStringThe key of the matched variation
reasonStringWhy this value was returned
prerequisiteKeyString?The prerequisite flag that failed, when reason is prerequisite-failed and that flag is visible to client SDKs

The SDK passes on the reason the server gives, unchanged:

ValueDescription
"fallthrough"No rules matched, so the fallthrough variation was served
"rule-match:<ruleId>"The targeting rule with that id matched the context
"flag-disabled"The flag is turned off in this environment, so the off variation was served
"prerequisite-failed"A prerequisite flag did not serve its required variation, so the off variation was served
"error"The server could not evaluate the flag and served the off variation
"TEST"Returned by test clients
PlatformMinimum Version
Android API26
Kotlin2.3+ (Java-only apps need no Kotlin plugin)
Java17+