Android SDK Reference
TL;DR: Add
io.featureflip:featureflip-android, callclient.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.
Installation
Section titled “Installation”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.
Configuration
Section titled “Configuration”The FeatureflipConfig data class accepts the following options:
| Option | Type | Default | Description |
|---|---|---|---|
clientKey | String | required | Client SDK key from your project settings |
baseUrl | String | "https://eval.featureflip.io" | Evaluation API base URL |
context | Map<String, Any?> | emptyMap() | Initial evaluation context (user attributes) |
streaming | Boolean | true | Enable SSE for real-time flag updates |
pollIntervalMs | Long | 30_000 | Polling interval in milliseconds (used when streaming is disabled) |
flushIntervalMs | Long | 30_000 | Interval in milliseconds between event flush batches |
flushBatchSize | Int | 100 | Maximum number of events per flush batch |
initTimeoutMs | Long | 10_000 | Maximum time in milliseconds to wait for initial flag fetch |
applicationContext | Any? | null | Your 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 |
inspectors | List<EvaluationInspector> | emptyList() | Callbacks run on every variation call. See Evaluation inspectors |
sendEvaluationEvents | Boolean | true | Report which flags the app reads, so flags it no longer reads can be archived. See Read reporting |
wrapperName | String? | null | For libraries that wrap the SDK: a name appended to the User-Agent after the SDK’s own token |
wrapperVersion | String? | null | Version sent with wrapperName |
Example configuration
Section titled “Example configuration”val config = FeatureflipConfig( clientKey = "your-client-sdk-key", context = mapOf("user_id" to "u-123"), streaming = true, pollIntervalMs = 60_000, initTimeoutMs = 15_000,)Initialization
Section titled “Initialization”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.FeatureflipClientimport 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.
Lifetime
Section titled “Lifetime”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 refcount1. - Subsequent
get()calls with the same key return a new handle and atomically increment the refcount. Theconfigargument 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
AtomicBooleanand 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 yourApplication.onCreate(), inject the handle everywhere,close()it inonTerminate()(if you care about clean shutdown in tests). - Per-Activity or per-ViewModel access: calling
get()in each Activity’sonCreate()or each ViewModel constructor is harmless. Each call returns a fresh handle on the same underlying shared core. Close the handle in the correspondingonDestroy()/ ViewModelonCleared()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.
isInitialized
Section titled “isInitialized”client.isInitialized: BooleanA 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.
Evaluating Flags
Section titled “Evaluating Flags”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.
boolVariation(key, defaultValue)
Section titled “boolVariation(key, defaultValue)”val enabled = client.boolVariation("dark-mode", false)stringVariation(key, defaultValue)
Section titled “stringVariation(key, defaultValue)”val color = client.stringVariation("button-color", "blue")numberVariation(key, defaultValue)
Section titled “numberVariation(key, defaultValue)”val limit = client.numberVariation("rate-limit", 100.0)Returns a Double.
jsonVariation(key, defaultValue)
Section titled “jsonVariation(key, defaultValue)”val config = client.jsonVariation("ui-config", null)Returns Any? — the raw deserialized value.
flagDetail(key)
Section titled “flagDetail(key)”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.
Evaluation inspectors
Section titled “Evaluation inspectors”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.
Identifying Users
Section titled “Identifying Users”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.
Event Tracking
Section titled “Event Tracking”track(eventName, metadata?)
Section titled “track(eventName, metadata?)”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.
flush()
Section titled “flush()”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()flushAndAwait()
Section titled “flushAndAwait()”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()Read reporting
Section titled “Read reporting”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.
Cleanup
Section titled “Cleanup”close()
Section titled “close()”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()closeAndAwait()
Section titled “closeAndAwait()”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()Lifecycle management
Section titled “Lifecycle management”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.
Testing
Section titled “Testing”FeatureflipClient.forTesting(overrides)
Section titled “FeatureflipClient.forTesting(overrides)”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) // trueclient.stringVariation("pricing-tier", "free") // "enterprise"client.boolVariation("unknown", false) // false (default)Signature:
fun forTesting(overrides: Map<String, Any?>): FeatureflipClientSupported override value types: Boolean, String, Int, Double.
FlagValue
Section titled “FlagValue”One flag as the server sent it. flagDetail returns it:
| Property | Type | Description |
|---|---|---|
value | Any? | The evaluated flag value |
variation | String | The key of the matched variation |
reason | String | Why this value was returned |
prerequisiteKey | String? | The prerequisite flag that failed, when reason is prerequisite-failed and that flag is visible to client SDKs |
Flag reasons
Section titled “Flag reasons”The SDK passes on the reason the server gives, unchanged:
| Value | Description |
|---|---|
"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 |
Requirements
Section titled “Requirements”| Platform | Minimum Version |
|---|---|
| Android API | 26 |
| Kotlin | 2.3+ (Java-only apps need no Kotlin plugin) |
| Java | 17+ |
See also
Section titled “See also”- Android Quickstart — get started in under 5 minutes
- Targeting & Segments — control which users see which variation
- Evaluation API — REST API reference
- SDK Overview — compare server-side and client-side SDKs