Android SDK
Get your Featureflow account at featureflow.com
GitHub: https://github.com/featureflow/featureflow-android-sdk
The Featureflow client SDK for Android, written in Kotlin. It is a client-side SDK: it asks Featureflow for values already evaluated for one user, rather than downloading your rules and evaluating them on the device.
Installation
// build.gradle.kts
dependencies {
implementation("io.featureflow:featureflow-android-sdk:0.1.0")
}
Requirements
- minSdk 21
- Java 17 toolchain
- Kotlin 1.9+
The SDK depends only on kotlinx-coroutines and androidx.lifecycle. It uses
HttpURLConnection and org.json from the platform rather than OkHttp or a JSON library, so it
cannot conflict with your app's HTTP stack or force a version of one on you.
It needs the internet permission, which it declares itself — you do not need to add it:
<uses-permission android:name="android.permission.INTERNET" />
Getting your key
Go to Environments → (your environment) → API Keys in the Featureflow dashboard and copy the
Client SDK key. It starts with sdk-js-env-.
Use the client key, never the server key.
A sdk-js-env- key is public by design — it only ever returns already-evaluated values for a
single user, which is why it is safe to ship. A sdk-srv-env- key downloads your entire ruleset,
including every targeting rule and attribute name, and anyone can extract it from a shipped APK.
If a server key has ever been built into an app, rotate it.
Quick start
val user = FeatureflowUser.Builder("user-123")
.withAttribute("tier", "gold")
.withAttributes("roles", listOf("beta"))
.build()
val featureflow = FeatureflowClient.initialize(
context = applicationContext,
apiKey = "sdk-js-env-YOUR_KEY",
user = user
)
if (featureflow.evaluate("new-checkout").isOn()) {
// the new checkout
} else {
// the old one
}
initialize is a suspend function that waits for the first evaluation, so calling it before
rendering flag-driven UI avoids a visible variant swap on launch. It never throws — if Featureflow
is unreachable the client falls back to the on-disk cache, and then to your configured defaults,
because a flag service being down must never stop your app from starting.
Create one client and share it. A second instance double-counts impressions and can disagree
with the first about a flag. Application.onCreate or your DI graph is the usual place; because
initialize suspends, launch it in a coroutine:
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
CoroutineScope(Dispatchers.IO).launch {
FeatureflowClient.initialize(this@MyApplication, BuildConfig.FEATUREFLOW_KEY)
}
}
}
Pass an application context. The SDK holds no reference to an Activity. If you would rather
not thread the client through your app, FeatureflowClient.get() returns the instance that
initialize created, or null before then.
Omit the user argument, as above, and the SDK generates an anonymous id and persists it.
Evaluating features
val evaluation = featureflow.evaluate("checkout-layout")
evaluation.isOn() // variant == "on"
evaluation.isOff() // variant == "off"
evaluation.`is`("wizard") // any variant, compared exactly
evaluation.value() // "wizard" — exactly as defined in the dashboard
evaluation.jsonValue() // the variant's JSON config payload, if any
is is a Kotlin keyword, so it needs backticks at the call site. From Java it is just
evaluation.is("wizard").
evaluate is synchronous and cheap — it reads the already-fetched evaluation, so it is safe to
call directly in a composable or an adapter's onBindViewHolder, and there is no need to cache
the result yourself.
Each evaluate call records an impression. Impressions are summarised into a per-variant count
rather than sent individually, so a flag read inside a composable that recomposes on every state
change does not post an event per frame.
Use peek() where a read does not mean the user was actually exposed to the feature — debug
screens, diagnostics, admin panels:
val evaluation = featureflow.peek("new-checkout") // records no impression
Impressions drive experiment results and stale-flag detection, so keeping non-exposures out of them matters.
allFeatures() returns every feature and its evaluated variant, and also records no impressions.
Variant config values
A variant can carry a JSON config value, so a flag can change a value rather than a code path:
val maxUploads = featureflow.evaluate("upload-limits")
.jsonValue()?.get("maxUploads")?.intValue ?: 10 // always have a fallback
jsonValue() returns null when the resolved variant has no JSON set, and the typed accessors
(stringValue, intValue, doubleValue, booleanValue, arrayValue, objectValue) return
null when the value is not of that type, so always supply a fallback.
Jetpack Compose
features is a StateFlow of every feature to its current variant, so the UI follows a rollout
without a relaunch:
@Composable
fun Checkout(featureflow: FeatureflowClient) {
val features by featureflow.features.collectAsState()
if (features["new-checkout"] == "on") {
NewCheckout()
} else {
LegacyCheckout()
}
}
Reading through the flow records no impression, while evaluate() does. That split is
deliberate: an impression is meant to mean the user was exposed to the feature, and a
recomposition is not exposure — the same composable may re-read the map dozens of times for one
screen the user sees once. Call evaluate() at the point exposure actually happens, typically in
the branch you took or in a LaunchedEffect:
if (features["new-checkout"] == "on") {
LaunchedEffect(Unit) { featureflow.evaluate("new-checkout") }
NewCheckout()
}
The flow starts empty and is populated once an evaluation has been applied, from the network or
the cache. Until then a lookup returns null, so treat a missing key as "not ready yet" rather than
as off, and check featureflow.isReady if you need to tell the two apart. In offline mode the
flow is not populated at all — read through evaluate() or peek() there, which serve your
defaultVariants.
Views
For plain View-based code, register a listener and re-apply when the variants change. The listener is called with the full current map:
class CheckoutActivity : AppCompatActivity() {
private val listener: (Map<String, String>) -> Unit = { applyFlags() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
FeatureflowClient.get()?.addListener(listener)
applyFlags()
}
override fun onDestroy() {
FeatureflowClient.get()?.removeListener(listener)
super.onDestroy()
}
}
Always remove the listener. The client lives for the whole process, so a listener that closes
over an Activity keeps that Activity — and its whole view hierarchy — alive for the life of the
app. Hold the lambda in a property, as above, so removeListener is given the same instance you
added; a fresh lambda literal will not match. removeAllListeners() clears the lot.
A listener that throws is caught and logged, so one bad callback cannot stop the others or break the poll loop.
Users and targeting
Targeting rules match on the attributes you supply, built with the fluent builder:
val user = FeatureflowUser.Builder("user-123")
.withAttribute("tier", "gold")
.withAttribute("age", 32)
.withAttribute("beta", true)
.withAttributes("roles", listOf("admin", "tester"))
.withSessionAttribute("dayofweek", JsonValue.of(5))
.build()
Attributes may be strings, numbers, booleans, dates or lists of strings, and a rule matches when any element of a list matches. Session attributes are used for the evaluation but are not persisted against the user in Featureflow for later rule-building.
The user id must be stable for the same person across launches.
The id is what percentage rollouts bucket on. An id that changes per launch re-buckets the user every time, which turns "10% of users" into "10% of sessions" and makes a rollout look as though it is flapping on and off. Use your own account id.
Do not use the Android ID or an advertising ID. They change on reset and reinstall, so they are not stable, and they carry privacy obligations you do not need for flag bucketing.
Omit the user entirely and the SDK generates an anonymous id and persists it, which keeps a signed-out user in a consistent bucket across launches.
On login and logout
featureflow.updateUser(loggedInUser) // suspend; re-evaluates everything
Call updateUser on login, on logout, and whenever an attribute used in targeting changes. Queued
impressions are flushed first so they stay attributed to the user who generated them. The on-disk
cache is keyed by API key and user id, so a new user is never served the previous user's values.
On logout, call resetAnonymousId() to issue a fresh anonymous id, so the signed-out user does
not stay in the buckets the account was in. It does not re-evaluate on its own — follow it with
updateUser.