Skip to content

Repository files navigation

FeatBit Client-Side SDK for Kotlin (Android)

Introduction

This is a Kotlin/Android client-side SDK for the 100% open-source feature flags management platform FeatBit, built because FeatBit does not currently ship a native Kotlin SDK. Its architecture and API are based on the official .NET Client-Side SDK; real-time streaming sync is modeled on FeatBit's JS/React-Native SDK and /streaming wire protocol.

Be aware, this is a client-side SDK intended for use in a single-user context — mobile, desktop, or embedded applications. It is not intended for multi-user systems such as web servers. For server-side use, see FeatBit's server SDKs.

Getting Started

Installation

Artifacts are built on demand by JitPack whenever a consumer requests a git tag from this repository, so no separate publish step is needed.

Add the JitPack repository in settings.gradle.kts:

dependencyResolutionManagement {
    repositories {
        mavenCentral()
        maven { url = uri("https://jitpack.io") }
    }
}

Then depend on either the core SDK or, on Android, the optional lifecycle connector:

dependencies {
    // Core SDK — flag evaluation, polling, streaming, event tracking.
    implementation("com.github.Fluent-Health.featbit-android-sdk:featbit-client:<tag>")

    // Optional: Android lifecycle glue. Depends on the core SDK transitively.
    implementation("com.github.Fluent-Health.featbit-android-sdk:featbit-client-android:<tag>")
}

Replace <tag> with a released git tag (e.g. v0.1.0). JitPack pins the artifact to the exact commit that tag points at, so the version you see is the version you get.

The library requires minSdk 21 and the INTERNET permission (declared by the library manifest and merged automatically).

Groovy DSL is identical apart from the string form: implementation 'com.github.Fluent-Health.featbit-android-sdk:featbit-client:<tag>'.

Prerequisite

Before using the SDK, obtain your environment secret and SDK URLs:

The polling URL is the streaming URL with the protocol swapped: ws(s)://<host>http(s)://<host>. Both resolve to the same evaluation server.

Quick Start

import co.featbit.client.FBClientImpl
import co.featbit.client.model.FBUser
import co.featbit.client.options.FBOptions
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds

// setup SDK options
val options = FBOptions.Builder("<replace-with-your-env-secret>")
    .polling("https://app-eval.featbit.co", interval = 5.minutes)
    .event("https://app-eval.featbit.co")
    .build()

// use an anonymous user as the initial user
val anonymousUser = FBUser.builder("anonymous")
    .name("anonymous")
    .custom("role", "visitor")
    .build()

val client = FBClientImpl(options, anonymousUser)

// start and wait up to 3 seconds for the client to be ready (suspend function)
val success = client.start(timeout = 3.seconds)
if (!success) {
    println("FBClient failed to initialize. Variation calls will use fallback values.")
}

// after the user logs in, switch users and fetch their flags
val authenticatedUser = FBUser.builder("a-unique-key-of-bob")
    .name("bob")
    .custom("country", "FR")
    .build()
client.identify(authenticatedUser)

// evaluate a boolean flag
val flagKey = "game-runner"
val enabled = client.boolVariation(flagKey, default = false)

// evaluate with reason
val detail = client.boolVariationDetail(flagKey, default = false)
println("flag '$flagKey' = ${detail.value} (reason: ${detail.reason})")

// subscribe to flag changes (listener)
client.flagTracker.subscribe(flagKey) { e ->
    println("'${e.key}' changed from '${e.oldValue}' to '${e.newValue}'")
}

// ...or collect them idiomatically from a coroutine
// client.flagTracker.flagChanges.collect { e -> /* ... */ }

client.close()

A runnable Android example lives in example-app/.

SDK

Async model

This SDK aligns with the FeatBit JS/React-Native SDK model (a promise-like waitUntilReady() plus an update event stream), expressed idiomatically in Kotlin:

  • start(...) and identify(...) are suspend functions.
  • Flag evaluations (boolVariation, etc.) are synchronous reads from the in-memory store.
  • Flag changes are delivered via the FlagTracker listener API and a flagChanges: Flow.

Data synchronization

The SDK supports two modes:

  • Polling (default) — fetches flags over HTTP at an interval (ported from the .NET client SDK):
    FBOptions.Builder(secret).polling("https://app-eval.featbit.co", interval = 5.minutes)
  • Streaming — keeps a WebSocket open and receives flag changes in real time (modeled on the JS/RN SDK and FeatBit's /streaming protocol):
    FBOptions.Builder(secret).streaming("wss://app-eval.featbit.co")

Both feed the same in-memory store, so evaluation, FlagTracker, and flagChanges behave identically regardless of mode. The synchronizer retries with exponential backoff and an application-level heartbeat.

Lifecycle-aware streaming (Android)

WebSockets don't survive backgrounding/doze, and reconnecting blindly wastes battery. The SDK exposes neutral hooks so a streaming connection is dropped when the app backgrounds or goes offline and re-established (with an immediate resync) on foreground / reconnect:

client.setForeground(true /* or false */)
client.setNetworkAvailable(true /* or false */)

Transitions to inactive are debounced by FBOptions.Builder.backgroundGracePeriod(...) (default 20s) so brief app-switches/blips don't churn. Defaults are foreground + online, so a client whose hooks are never called streams as before.

For Android, the optional featbit-client-android artifact wires these hooks to ProcessLifecycleOwner + ConnectivityManager automatically — the core gains no extra dependency:

// e.g. in Application.onCreate(), on the main thread
FBLifecycleConnector(this, fbClient).start()

FBClient

FBClient is the heart of the SDK. Create a single instance for the lifetime of your app (register it as a singleton in your DI graph — Hilt/Koin/etc.).

FBUser

FBUser describes the user that flags are evaluated for. The only mandatory attribute is key. Add any number of custom string attributes with custom(key, value):

val bob = FBUser.builder("a-unique-key-of-bob")
    .name("bob")
    .custom("age", "15")
    .custom("country", "FR")
    .build()

Evaluating flags

For each type there is a *Variation method returning the value and a *VariationDetail returning an EvalDetail (value + reason):

  • boolVariation / boolVariationDetail
  • stringVariation / stringVariationDetail
  • intVariation / intVariationDetail
  • floatVariation / floatVariationDetail
  • doubleVariation / doubleVariationDetail

For JSON flags, use stringVariation to obtain the JSON string (a typed jsonVariation is on the roadmap).

Tracking flag changes

// all flags
client.flagTracker.subscribe { e -> /* ... */ }
// a specific flag
client.flagTracker.subscribe("game-runner") { e -> /* ... */ }
// unsubscribe with the same listener reference

Observing SDK health

FBClient.events() returns a hot Flow<FBEvent> carrying lifecycle + health signals so an application can surface transport / decode failures to its own observability stack (Sentry, Timber, structured logs) — the SDK itself never captures third-party observability.

// e.g. in Application.onCreate() alongside the FBLifecycleConnector wiring
appScope.launch {
    client.events().collect { event ->
        when (event) {
            FBEvent.Ready -> Timber.d("FeatBit ready.")
            FBEvent.Reconnecting -> Timber.d("FeatBit reconnecting.")
            is FBEvent.SyncError -> if (event.recoverable) {
                Timber.w(event.cause, "FeatBit sync error (recoverable).")
            } else {
                Timber.e(event.cause, "FeatBit sync failed.")
                Sentry.captureException(event.cause)
            }
            is FBEvent.TransportError -> Timber.w(event.cause, "FeatBit transport error.")
        }
    }
}

SyncError carries a recoverable: Boolean. It's true when the sync layer skipped a single malformed flag entry within an otherwise-valid batch or when the client is already initialised and the failure only affects the current batch; false when the very first initialisation failed and evaluations are still returning caller defaults. Subscribe before start() to observe the initial Ready emission.

Resilience to malformed server payloads

StreamingJson uses coerceInputValues = true, and both the streaming and polling paths apply per-flag try/catch isolation around store.upsert. If the server ever ships a malformed variation for one flag entry (e.g. a null on a non-null string field, which we have observed for json-typed flags with an unset variation slot), the SDK logs the failure, emits FBEvent.SyncError(recoverable = true), drops that entry, and continues processing the rest of the batch — instead of aborting the whole payload and leaving the client stuck in a not-ready state.

Offline mode & bootstrapping

val options = FBOptions.Builder()
    .offline(true)
    .bootstrap(savedFlags) // List<FeatureFlag>
    .build()

In offline mode no network calls are made; evaluations use bootstrap data (or fallback values). Use client.allFlags() to snapshot the current flags for later bootstrap.

Logging

Provide a FBLogger via FBOptions.Builder.logger(...) to route SDK logs into your app. The default DefaultLogger writes to Logcat (falling back to stdout/stderr off-device).

Building

This repo ships sources but not the Gradle wrapper JAR. Generate it once with a local Gradle (8.5+), then build:

gradle wrapper --gradle-version 8.9
./gradlew :featbit-client:assembleRelease   # produce the AAR
./gradlew :featbit-client:test              # run unit tests

End-to-end tests

A full-stack E2E (featbit-client/src/test/kotlin/co/featbit/client/e2e/) drives a real FBClient against a real FeatBit stack (Postgres + api-server + evaluation-server) started with Testcontainers. It seeds a flag via FeatBit's management API, then asserts the SDK evaluates it, observes a server-side toggle via polling, and re-identifies — over real HTTP.

It is gated by FEATBIT_E2E=1 so the default unit-test run never requires Docker:

FEATBIT_E2E=1 ./gradlew :featbit-client:testDebugUnitTest --tests "co.featbit.client.e2e.*"

Requirements: a reachable Docker daemon. FeatBit's Postgres schema is vendored under src/test/resources/e2e/initdb/ (Apache-2.0, from featbit/featbit). CI runs this as a separate job on every PR.

Supported versions

  • Android minSdk 21+, compileSdk 34.
  • Kotlin 1.9+, JVM target 11.

Backstage

This repository is Backstage-compatible: catalog-info.yaml registers it as a Component, and this documentation is published via TechDocs (mkdocs.yml + docs/).

catalog-info.yaml encodes organization-specific Backstage conventions — the owner, custom annotations, and the assumption that the descriptor is registered in an org root catalog. Adapt or remove these to match your own Backstage instance (or drop catalog-info.yaml/mkdocs.yml entirely if you don't use Backstage) — just as you supply your own FeatBit environment secret and evaluation-server URLs.

Contributing

Contributions are welcome — see CONTRIBUTING.md. To report a security issue, see SECURITY.md.

License

Apache-2.0 — see LICENSE and NOTICE. FeatBit and the FeatBit SDKs are works of the FeatBit project; this is an independent derivative port of their .NET client SDK and is not affiliated with or endorsed by FeatBit.

Maintained by Fluent Health.

About

FeatBit feature-flag client SDK for Kotlin/Android — local evaluation with real-time WebSocket streaming and lifecycle-aware sync.

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages