Skip to content

Providers

kirich1409 edited this page Jun 13, 2026 · 2 revisions

Providers

ConfigValues composes one optional local provider and one optional remote provider. At least one must be non-null. Remote values take precedence over local values when both are present for the same key.

ConfigValues
├── LocalConfigValueProvider  (optional, but at least one required)
└── RemoteConfigValueProvider (optional, but at least one required)

Built-in local providers

InMemoryConfigValueProvider

Stores overrides in a plain in-memory Map. No setup, no dependencies. Included in featured-core.

Use cases: unit tests, Compose previews, ephemeral runtime overrides.
Limitation: values are lost when the process terminates.

val configValues = ConfigValues(
    localProvider = InMemoryConfigValueProvider(),
)

Programmatic set and reset:

val provider = InMemoryConfigValueProvider()
provider.set(GeneratedLocalFlags.newCheckout, true)   // override
provider.resetOverride(GeneratedLocalFlags.newCheckout) // revert to default
provider.clear()                                       // remove all overrides (no Flow signal)

set and resetOverride notify active observe flows immediately. clear does not emit change signals.

DataStoreConfigValueProvider

Persists flag overrides using Jetpack DataStore. Recommended for Android.

Dependency: featured-datastore-provider

import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import dev.androidbroadcast.featured.datastore.DataStoreConfigValueProvider

val dataStore = PreferenceDataStoreFactory.create {
    context.dataStoreFile("feature_flags.preferences_pb")
}

val configValues = ConfigValues(
    localProvider = DataStoreConfigValueProvider(dataStore),
)

SharedPreferencesConfigValueProvider

Android-only. Persists overrides using SharedPreferences.

Dependency: featured-sharedpreferences-provider

import dev.androidbroadcast.featured.sharedpreferences.SharedPreferencesConfigValueProvider

val prefs = context.getSharedPreferences("feature_flags", Context.MODE_PRIVATE)

val configValues = ConfigValues(
    localProvider = SharedPreferencesConfigValueProvider(prefs),
)

JavaPreferencesConfigValueProvider

JVM/Desktop only. Persists overrides using java.util.prefs.Preferences. Storage is OS-specific: the registry on Windows, a plist on macOS, and ~/.java on Linux.

Dependency: featured-javaprefs-provider
Supported types: String, Int, Long, Float, Double, Boolean.

import dev.androidbroadcast.featured.javaprefs.JavaPreferencesConfigValueProvider

val provider = JavaPreferencesConfigValueProvider()
// Or with a custom node:
val provider = JavaPreferencesConfigValueProvider(
    node = Preferences.userRoot().node("com/example/myapp/flags")
)

val configValues = ConfigValues(localProvider = provider)

Register a TypeConverter for custom types:

provider.registerConverter<Theme>(
    TypeConverter(fromString = { Theme.valueOf(it) }, toString = { it.name })
)

NSUserDefaultsConfigValueProvider

iOS/macOS only. Persists flag overrides using NSUserDefaults. Values survive process restarts and are stored in the standard user defaults domain or a custom app-group suite.

Dependency: featured-nsuserdefaults-provider
Supported types natively: String, Int, Long, Float, Double, Boolean. Custom types (e.g. enums) are supported via registerConverter.

import dev.androidbroadcast.featured.nsuserdefaults.NSUserDefaultsConfigValueProvider

// Standard user defaults
val provider = NSUserDefaultsConfigValueProvider()

// Custom suite (e.g. for App Groups)
val provider = NSUserDefaultsConfigValueProvider(suiteName = "com.example.app.flags")

val configValues = ConfigValues(localProvider = provider)

set and resetOverride notify active observe flows immediately. clear removes all keys but does not emit change signals.

Register a TypeConverter for custom types such as enums:

import dev.androidbroadcast.featured.nsuserdefaults.registerConverter
import dev.androidbroadcast.featured.enumConverter

provider.registerConverter(enumConverter<CheckoutVariant>())

The inline registerConverter<T>(converter) overload avoids passing KClass explicitly. Non-primitive values are serialized to String via the converter and stored as string objects in NSUserDefaults. This mirrors the DataStoreConfigValueProvider and JavaPreferencesConfigValueProvider converter API.

Built-in remote providers

ConfigCatConfigValueProvider

Wraps the ConfigCat KMP SDK. Remote values override local values when present.

Dependency: featured-configcat-provider
Supported types: Boolean, String, Int, Long, Double, Float.

import com.configcat.ConfigCatClient
import dev.androidbroadcast.featured.configcat.ConfigCatConfigValueProvider

val client = ConfigCatClient(sdkKey = "YOUR_SDK_KEY")
val provider = ConfigCatConfigValueProvider(client)

val configValues = ConfigValues(remoteProvider = provider)

// Fetch latest values — suspend function, call from a coroutine
lifecycleScope.launch { configValues.fetch() }

fetch() calls ConfigCatClient.forceRefresh() and activates updated values immediately.

FirebaseConfigValueProvider

Wraps Firebase Remote Config. Remote values override local values when present.

Dependency: featured-firebase-provider
Supported types natively: String, Boolean, Int, Long, Double, Float. Enums are resolved by name automatically.

import dev.androidbroadcast.featured.firebase.FirebaseConfigValueProvider

val configValues = ConfigValues(
    localProvider = DataStoreConfigValueProvider(dataStore),
    remoteProvider = FirebaseConfigValueProvider(),
)

// Fetch and activate on app start — suspend function, call from a coroutine
lifecycleScope.launch { configValues.fetch() }

Pass a custom FirebaseRemoteConfig instance if you manage the Firebase lifecycle yourself:

FirebaseConfigValueProvider(remoteConfig = FirebaseRemoteConfig.getInstance())

Fetch strategy:

  • configValues.fetch() calls fetchAndActivate() by default — values are immediately available after the call returns.
  • Pass activate = false to defer activation: configValues.fetch(activate = false).
  • A FirebaseConfigException is thrown on network errors or service unavailability; implement exponential backoff for retries.

FirebaseConfigValueProvider implements InitializableConfigValueProvider. Calling configValues.initialize() invokes FirebaseRemoteConfig.ensureInitialized(), which loads the on-disk cache (activated values, fetched-but-not-yet-activated values, and defaults) into memory without a network call. This makes previously persisted Remote Config values readable via getValueCached immediately at app start, before the first fetch().

// Recommended startup sequence
lifecycleScope.launch {
    configValues.initialize() // loads disk cache — no network
    // getValueCached is now reliable for all warmed params
    configValues.fetch()      // network fetch + activate
}

initialize() does not activate fetched-but-unactivated config — call fetch() for that.

Firebase project setup:

  1. Add google-services.json (Android) or GoogleService-Info.plist (iOS) to the project.
  2. In the Firebase console, navigate to Remote Config and add parameters whose keys match ConfigParam.key values.
  3. Publish, then call configValues.initialize() followed by configValues.fetch() at app start.

Provider error handling

ConfigValues accepts an onProviderError: (Throwable) -> Unit callback that is invoked whenever a provider operation fails (a get, observe, set, resetOverride, or fetch that throws). The default behavior logs the error to the platform log:

  • Android: Log.w("Featured", …)
  • iOS: NSLog
  • JVM / Desktop: System.err

To silence provider errors entirely, pass onProviderError = {}. To route through your own logger:

val configValues = ConfigValues(
    localProvider = DataStoreConfigValueProvider(dataStore),
    remoteProvider = FirebaseConfigValueProvider(),
    onProviderError = { e -> Timber.w(e, "Featured provider error") },
)

Provider resolution order

When both local and remote providers return a value for the same key, remote wins. If only one provider returns a value, that value is used. If neither returns a value, the ConfigParam.defaultValue is used and the source is ConfigValue.Source.DEFAULT.

Local Remote Result Source
defaultValue DEFAULT
present local value LOCAL
present remote value REMOTE
present present remote value REMOTE

Writing a custom provider

Implement LocalConfigValueProvider for a writable, observable local backend:

class MyLocalProvider : LocalConfigValueProvider {
    override suspend fun <T : Any> get(param: ConfigParam<T>): ConfigValue<T>? { … }
    override fun <T : Any> observe(param: ConfigParam<T>): Flow<ConfigValue<T>> { … }
    override suspend fun <T : Any> set(param: ConfigParam<T>, value: T) { … }
    override suspend fun <T : Any> resetOverride(param: ConfigParam<T>) { … }
    override suspend fun clear() { … }
}

observe emission contract: the returned Flow must emit exactly one value immediately upon collection (the current stored value, or null if absent), then emit again on every subsequent change. This is a normative contract — ConfigValues relies on the initial emission to build its reactive pipeline.

Implement RemoteConfigValueProvider for a fetch-based remote backend:

class MyRemoteProvider : RemoteConfigValueProvider {
    override suspend fun fetch(activate: Boolean) { /* fetch from your backend */ }
    override suspend fun <T : Any> get(param: ConfigParam<T>): ConfigValue<T>? { … }
    override fun <T : Any> observe(param: ConfigParam<T>): Flow<ConfigValue<T>> { … }
}

Implement InitializableConfigValueProvider in addition to RemoteConfigValueProvider if your backend supports loading a local disk/memory cache before the first network fetch:

class MyRemoteProvider : RemoteConfigValueProvider, InitializableConfigValueProvider {
    override suspend fun initialize() { /* load on-disk cache into memory */ }
    // … rest of RemoteConfigValueProvider
}

When implemented, ConfigValues.initialize() calls initialize() on the remote provider and then refreshes every param previously registered via warmUp.

Clone this wiki locally