diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index 3c0031e..0000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,79 +0,0 @@ -name: Publish Docs - -on: - push: - branches: - - main - - develop - tags: - - "v[0-9]+.[0-9]+.[0-9]+" - - "v[0-9]+.[0-9]+.[0-9]+-*" - pull_request: - branches: - - main - - develop - -permissions: - contents: write - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - build-docs: - name: Build Docs - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v6 - - - uses: ./.github/actions/setup-build-env - - - name: Generate Dokka HTML - run: ./gradlew --no-daemon dokkaGenerate - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Install MkDocs Material - run: pip install mkdocs-material==9.5.18 - - - name: Build MkDocs site - run: mkdocs build --strict - - - name: Embed Dokka output into site/api - # Runs after mkdocs build so Dokka HTML does not conflict with - # docs/api/index.md during MkDocs processing. - run: cp -r build/dokka/html/. site/api/ - - - name: Upload site artifact - uses: actions/upload-artifact@v7 - with: - name: docs-site - path: site/ - retention-days: 7 - - publish-docs: - name: Publish to GitHub Pages - runs-on: ubuntu-latest - needs: build-docs - # Only publish on pushes to main or on release tags — not on PRs or develop - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')) - - steps: - - uses: actions/checkout@v6 - - - name: Download site artifact - uses: actions/download-artifact@v8 - with: - name: docs-site - path: site/ - - - name: Publish to GitHub Pages - uses: peaceiris/actions-gh-pages@v4 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./site diff --git a/.gitignore b/.gitignore index 6cadee1..0e32406 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,9 @@ captures /docs/superpowers/ /docs/adr/ /.claude/worktrees/ +/.claude/agent-memory/ +/.claude/scheduled_tasks.lock +/.wiki/ +/.build/ +secring.gpg +*.gpg diff --git a/README.md b/README.md index c59cf15..30c3574 100644 --- a/README.md +++ b/README.md @@ -4,586 +4,72 @@ [![Maven Central](https://img.shields.io/maven-central/v/dev.androidbroadcast.featured/featured-core.svg?label=Maven%20Central)](https://central.sonatype.com/search?q=dev.androidbroadcast.featured) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -**Featured** is a type-safe, reactive feature-flag and configuration management library for Kotlin Multiplatform (Android, iOS, JVM). Declare flags in shared Kotlin code, read them at runtime from local or remote providers, and let the Gradle plugin dead-code-eliminate disabled flags from your production binaries. +Featured is a type-safe, reactive feature-flag and configuration management library for Kotlin Multiplatform — Android, iOS (via SKIE), and JVM. -## Table of contents +## Highlights -- [Overview](#overview) -- [Installation](#installation) -- [Quick start](#quick-start) -- [Using flags at runtime](#using-flags-at-runtime) -- [Providers](#providers) -- [Debug UI](#debug-ui) -- [Release build optimization](#release-build-optimization) -- [iOS integration](#ios-integration) -- [Multi-module setup](#multi-module-setup) -- [Configuration cache](#configuration-cache) -- [API reference](#api-reference) +- **Type-safe flags** — declared in the Gradle DSL, accessed via generated typed extensions on `ConfigValues`. No string keys, no unchecked casts. +- **Dead-code elimination in release builds** — a flag with `default = false` makes the guarded code unreachable. The Gradle plugin emits R8 `-assumevalues` rules (Android/JVM) and an xcconfig with `DISABLE_` Swift compilation conditions (iOS), so the respective compilers physically strip disabled branches from release binaries. +- **Reactive** — every value is observable via `Flow`; Compose and SwiftUI/Combine integrations included. +- **Multiple providers** — DataStore, SharedPreferences, NSUserDefaults, JavaPreferences, Firebase Remote Config, ConfigCat, or a custom one. +- **Debug UI** — a ready-made Compose screen for overriding flags at runtime. ---- - -## Overview - -**Use cases** - -- Ship code guarded by a flag that is off by default; enable it via Firebase Remote Config when you are ready to roll out. -- Override individual flags during development or QA without touching a remote backend. -- Eliminate dead code from Release binaries: the Gradle plugin generates R8 rules (Android/JVM) and an xcconfig file (iOS) that let the respective compilers strip disabled flag code paths at build time. - -**Key types** - -| Type | Role | -|------|------| -| `ConfigParam` | Declares a named, typed configuration key with a default value | -| `ConfigValue` | Wraps a param's current value and its source (DEFAULT / LOCAL / REMOTE) | -| `ConfigValues` | Container that composes local and remote providers | -| `LocalConfigValueProvider` | Interface for writable, observable local storage | -| `RemoteConfigValueProvider` | Interface for fetch-based remote configuration | - ---- - -## Installation - -### Gradle version catalog - -Add the BOM to manage all module versions from a single place, then declare only the artifacts you need. +## Quick example ```kotlin -// settings.gradle.kts -dependencyResolutionManagement { - repositories { - mavenCentral() - google() - } -} -``` - -```kotlin -// build.gradle.kts (root or app module) +// build.gradle.kts — declare the flag plugins { id("dev.androidbroadcast.featured") version "" } dependencies { implementation(platform("dev.androidbroadcast.featured:featured-bom:")) - - // Core runtime — always required implementation("dev.androidbroadcast.featured:featured-core") - - // Optional modules — add only what you use - implementation("dev.androidbroadcast.featured:featured-compose") // Compose extensions - debugImplementation("dev.androidbroadcast.featured:featured-registry") // Flag registry for debug UI - debugImplementation("dev.androidbroadcast.featured:featured-debug-ui") // Debug screen - - // Local persistence providers — pick one (or both) implementation("dev.androidbroadcast.featured:featured-datastore-provider") - implementation("dev.androidbroadcast.featured:featured-sharedpreferences-provider") - - // Remote provider - implementation("dev.androidbroadcast.featured:featured-firebase-provider") } -``` - -> The Gradle plugin ID is `dev.androidbroadcast.featured`. It is also published to Maven Central under the artifact `dev.androidbroadcast.featured:featured-gradle-plugin`. - -### iOS — Swift Package Manager - -Add the package in Xcode (**File › Add Package Dependencies**) or in `Package.swift`: - -```swift -.package( - url: "https://github.com/AndroidBroadcast/Featured", - from: "" -) -``` - -Then add `FeaturedCore` as a target dependency: - -```swift -.target( - name: "MyApp", - dependencies: [ - .product(name: "FeaturedCore", package: "Featured") - ] -) -``` ---- - -## Quick start - -### 1. Declare a flag - -Declare flags in `build.gradle.kts` using the `featured { }` DSL block. The plugin generates typed helpers automatically. - -```kotlin title="build.gradle.kts" featured { localFlags { boolean("new_checkout", default = false) { description = "Enable the new checkout flow" - category = "Checkout" - } - int("max_cart_items", default = 10) { - description = "Maximum items allowed in cart" } } } ``` -The plugin generates `internal object GeneratedLocalFlags` with typed `ConfigParam` properties, and public extension functions on `ConfigValues` — for example `fun ConfigValues.isNewCheckoutEnabled(): Boolean` and `fun ConfigValues.getMaxCartItems(): Int`. - -### 2. Create a `ConfigValues` instance - -Wire up providers once, typically in your dependency injection setup or `Application.onCreate`. - ```kotlin -// Android -val configValues = ConfigValues( - localProvider = DataStoreConfigValueProvider(preferencesDataStore), - remoteProvider = FirebaseConfigValueProvider(), -) -``` - -`ConfigValues` requires at least one provider. Both `localProvider` and `remoteProvider` are optional individually, but at least one must be non-null. +// Application.kt — wire up ConfigValues once +val dataStore = PreferenceDataStoreFactory.create { context.dataStoreFile("feature_flags.preferences_pb") } -### 3. Read a flag value - -```kotlin -// Suspend function — call from a coroutine -val value: ConfigValue = configValues.getValue(FeatureFlags.newCheckout) -val isEnabled: Boolean = value.value // the actual value -val source: ConfigValue.Source = value.source // DEFAULT, LOCAL, or REMOTE -``` - ---- - -## Using flags at runtime - -### One-shot read - -```kotlin -val configValue: ConfigValue = configValues.getValue(FeatureFlags.newCheckout) -if (configValue.value) { - // feature is active -} -``` - -### Reactive observation (Flow) - -```kotlin -// Emits immediately with the current value, then on every change -configValues.observe(FeatureFlags.newCheckout) - .collect { configValue -> - println("new_checkout = ${configValue.value} (source: ${configValue.source})") - } - -// Convenience: emit only the raw value, not the ConfigValue wrapper -configValues.observeValue(FeatureFlags.newCheckout) - .collect { isEnabled: Boolean -> /* … */ } - -// Convert to StateFlow -val isEnabled: StateFlow = configValues.asStateFlow( - param = FeatureFlags.newCheckout, - scope = viewModelScope, -) -``` - -### Compose extension - -```kotlin -@Composable -fun CheckoutScreen(configValues: ConfigValues) { - val isEnabled: State = configValues.collectAsState(FeatureFlags.newCheckout) - - if (isEnabled.value) { - NewCheckoutContent() - } else { - LegacyCheckoutContent() - } -} -``` - -Use `LocalConfigValues` to provide a `ConfigValues` through the composition tree: - -```kotlin -// In your root composable -CompositionLocalProvider(LocalConfigValues provides configValues) { - AppContent() -} - -// Anywhere below -@Composable -fun SomeDeepComponent() { - val configValues = LocalConfigValues.current - val enabled by configValues.collectAsState(FeatureFlags.newCheckout) - // … -} -``` - -### iOS (Swift) - -The `FeatureFlags` Swift class wraps `CoreConfigValues` (the KMP-exported type). Define your flags as `FeatureFlag` values that reference the shared `CoreConfigParam` exported from Kotlin: - -```swift -import FeaturedCore - -// Map a Kotlin ConfigParam to a Swift FeatureFlag -let newCheckoutFlag = FeatureFlag( - param: CoreFeatureFlagsCompanion().newCheckout, - defaultValue: false -) - -let featureFlags = FeatureFlags(configValues) - -// Async read -let isEnabled = try await featureFlags.value(of: newCheckoutFlag) - -// AsyncStream — use in a Task or async for-await loop -for await value in featureFlags.stream(of: newCheckoutFlag) { - updateUI(value) -} - -// Combine publisher -featureFlags.publisher(for: newCheckoutFlag) - .receive(on: DispatchQueue.main) - .sink { isEnabled in updateUI(isEnabled) } - .store(in: &cancellables) -``` - ---- - -## Providers - -### InMemoryConfigValueProvider (built-in) - -No setup required. Values are stored in memory and lost on process restart. Useful for tests and previews. - -```kotlin -val configValues = ConfigValues( - localProvider = InMemoryConfigValueProvider(), -) -``` - -### DataStoreConfigValueProvider - -Persists overrides to Jetpack DataStore Preferences. - -```kotlin -// Declare once per file, outside any function or class -private val Context.featureFlagsDataStore: DataStore - by preferencesDataStore(name = "feature_flags") - -val configValues = ConfigValues( - localProvider = DataStoreConfigValueProvider(context.featureFlagsDataStore), -) -``` - -### SharedPreferencesProviderConfig - -Android-only. Persists overrides to SharedPreferences. - -```kotlin -val prefs = context.getSharedPreferences("feature_flags", Context.MODE_PRIVATE) - -val configValues = ConfigValues( - localProvider = SharedPreferencesProviderConfig(prefs), -) -``` - -### FirebaseConfigValueProvider (remote) - -Wraps Firebase Remote Config. Remote values override local values. - -```kotlin val configValues = ConfigValues( localProvider = DataStoreConfigValueProvider(dataStore), - remoteProvider = FirebaseConfigValueProvider(), ) - -// Fetch and activate — suspend function, call from a coroutine (e.g., on app start) -lifecycleScope.launch { configValues.fetch() } ``` -`FirebaseConfigValueProvider` uses `FirebaseRemoteConfig.getInstance()` by default. Pass a custom instance if you manage the Firebase lifecycle yourself: - ```kotlin -FirebaseConfigValueProvider(remoteConfig = FirebaseRemoteConfig.getInstance()) +// Read the generated extension anywhere +val isEnabled: Boolean = configValues.isNewCheckoutEnabled() ``` -### Override and reset at runtime - -```kotlin -// Write a local override — survives remote fetches -configValues.override(FeatureFlags.newCheckout, true) - -// Revert to the provider's stored or default value -configValues.resetOverride(FeatureFlags.newCheckout) -``` - ---- - -## Debug UI - -`featured-debug-ui` provides a ready-made Compose screen that lists all registered flags with their current values and sources, and lets you toggle or override them at runtime. - -### 1. Register flags - -Register each `ConfigParam` in the `FlagRegistry` so the debug screen can discover them: - -```kotlin -import dev.androidbroadcast.featured.registry.FlagRegistry - -// Call once on app start (e.g., in Application.onCreate or your DI module) -FlagRegistry.register(FeatureFlags.newCheckout) -FlagRegistry.register(FeatureFlags.maxCartItems) -``` - -### 2. Show the debug screen - -```kotlin -import dev.androidbroadcast.featured.debugui.FeatureFlagsDebugScreen - -@Composable -fun DebugMenuScreen(configValues: ConfigValues) { - FeatureFlagsDebugScreen(configValues = configValues) -} -``` - -Only include `featured-debug-ui` and `featured-registry` in debug builds (they are already declared that way in the installation section above): - ---- - -## Release build optimization - -### Android / JVM — R8 rules - -The Gradle plugin generates per-function ProGuard / R8 `-assumevalues` rules for the generated extension functions of every local boolean flag with `default = false`. These rules instruct R8 to treat the flag as a constant `false` at shrink time, so all code guarded by the generated accessor is removed from the release APK. Remote flags are excluded since their values are dynamic. - -The task runs automatically when you build a release variant. To run it manually: - -```bash -./gradlew :app:generateFeaturedProguardRules -``` - -Output: `app/build/featured/proguard-featured.pro` - -No extra configuration is needed — the plugin wires the output into the R8 pipeline automatically. - -### iOS — xcconfig for Swift DCE - -See the [iOS integration](#ios-integration) section below. - ---- - -## iOS integration - -The Gradle plugin generates an xcconfig file that feeds Swift compilation conditions into Xcode. For every local boolean flag declared in `featured { localFlags { } }` with `default = false`, a `DISABLE_` condition is generated. - -### Key transformation - -| Kotlin flag key | Generated condition | -|-----------------|---------------------| -| `new_checkout` | `DISABLE_NEW_CHECKOUT` | -| `experimentalUi` | `DISABLE_EXPERIMENTAL_UI` | - -### Step 1 — Generate the xcconfig - -```bash -./gradlew :shared:generateXcconfig -``` - -Output: `shared/build/featured/FeatureFlags.generated.xcconfig` - -Example content: - -```xcconfig -# Auto-generated by featured-gradle-plugin — do not edit -SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) DISABLE_NEW_CHECKOUT DISABLE_EXPERIMENTAL_UI -``` - -### Step 2 — Make the file available to Xcode - -Copy or symlink the file to a stable path inside your Xcode project tree: - -```bash -# Copy (re-run after each generateXcconfig invocation) -cp shared/build/featured/FeatureFlags.generated.xcconfig \ - iosApp/Configuration/FeatureFlags.generated.xcconfig - -# Symlink (resolved automatically) -ln -sf ../../shared/build/featured/FeatureFlags.generated.xcconfig \ - iosApp/Configuration/FeatureFlags.generated.xcconfig -``` - -Add the generated file to `.gitignore` if you use the copy approach: - -```gitignore -iosApp/Configuration/FeatureFlags.generated.xcconfig -``` - -### Step 3 — Configure Xcode (one-time) - -1. Open your `.xcodeproj` in Xcode. -2. Select the project in the Navigator → **Info** tab → **Configurations**. -3. Expand the **Release** configuration. -4. Set the configuration file for your app target to `FeatureFlags.generated.xcconfig`. - -Only assign the xcconfig to Release. Debug builds intentionally omit it so every feature remains reachable during development. - -### Step 4 — Guard Swift entry points with `#if` - -```swift -// Entry point for the new checkout feature -#if !DISABLE_NEW_CHECKOUT -NewCheckoutButton() -#endif - -// Deep-link handler -#if !DISABLE_NEW_CHECKOUT -case .newCheckout: NewCheckoutCoordinator.start() -#endif - -// AppDelegate / SceneDelegate -#if !DISABLE_NEW_CHECKOUT -setupNewCheckoutObservers() -#endif -``` - -The Swift compiler removes the entire guarded block from Release binaries — zero runtime overhead. - -### Automate with a pre-build Run Script phase - -Add this script to your Xcode target's Build Phases (before Compile Sources). Set **Based on dependency analysis** to **off**: - -```bash -cd "${SRCROOT}/.." -./gradlew :shared:generateXcconfig --quiet -cp shared/build/featured/FeatureFlags.generated.xcconfig \ - iosApp/Configuration/FeatureFlags.generated.xcconfig -``` - ---- - -## Multi-module setup - -In a multi-module project, apply the Gradle plugin to every module that declares flags in `featured { }`. The plugin registers a `resolveFeatureFlags` task per module and an aggregator task `scanAllLocalFlags` at the root that collects flags across all modules. - -```kotlin -// :feature:checkout module build.gradle.kts -plugins { - id("dev.androidbroadcast.featured") - // … other plugins -} -``` - -```kotlin -// :feature:profile module build.gradle.kts -plugins { - id("dev.androidbroadcast.featured") -} -``` - -Run code generation tasks across all modules at once: - -```bash -# Resolve and aggregate flags across all modules -./gradlew scanAllLocalFlags - -# Generate R8 rules for all Android modules -./gradlew generateFeaturedProguardRules - -# Generate xcconfig across all modules -./gradlew generateXcconfig -``` - -Declare a single shared `ConfigValues` in your app module and inject it into feature modules through dependency injection. Feature modules declare their own `ConfigParam` objects but do not create `ConfigValues` themselves. - ---- - -## Configuration cache - -`featured-gradle-plugin` officially supports the Gradle [Configuration Cache](https://docs.gradle.org/current/userguide/configuration_cache.html) on **Gradle 9+** and **AGP 9+**. Every task registered by the plugin (`resolveFeatureFlags`, `generateFeaturedProguardRules`, `generateConfigParam`, `generateFlagRegistrar`, `generateIosConstVal`, `generateXcconfig`, `scanAllLocalFlags`) stores and reuses CC entries without violations. - -### Enabling - -Add the following to `gradle.properties`: - -```properties -org.gradle.configuration-cache=true -``` - -### Known gap — AGP 9.x `proguardFiles` provider propagation - -AGP 9.x exposes `variant.proguardFiles` as a `ListProperty`, but on the AGP releases verified during the 1.0.0-Beta cycle (9.1.0) the provider's dependency does **not** propagate to the underlying R8 / minification tasks. As a result, wiring the plugin's generated `proguard-featured.pro` purely through `variant.proguardFiles.add(...)` is insufficient — the R8 task will not see the file as an input dependency and will run before the rules are generated. - -`featured-gradle-plugin` retains a `tasks.configureEach { … }` fallback inside [`AndroidProguardWiring.kt`](featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/AndroidProguardWiring.kt) that explicitly establishes the task dependency. The fallback is CC-safe (no `Project` reference at execution time, no eager configuration). It will be revisited on every AGP minor and removed when the upstream provider propagation gap is fixed. - -Audit artefact: [`docs/cc-verification/agp-propagation-check-2026-05-16.md`](docs/cc-verification/agp-propagation-check-2026-05-16.md). - -### Upstream limitations - -No known upstream Configuration Cache limitations attributable to third-party plugins were observed at time of release across the sample modules (`:sample:android-app`, `:sample:desktop`, `:sample:shared`). - -### Verification artefacts - -All verification artefacts live under `docs/cc-verification/`: - -- [`fixture-report-2026-05-17.md`](docs/cc-verification/fixture-report-2026-05-17.md) — plugin test fixture audit (AC-3). -- [`sample-report-2026-05-17.md`](docs/cc-verification/sample-report-2026-05-17.md) — sample modules audit (AC-4). -- [`agp-propagation-check-2026-05-16.md`](docs/cc-verification/agp-propagation-check-2026-05-16.md) — AGP `proguardFiles` provider propagation audit (AC-5a). - -Isolated-projects support is tracked separately — see [`docs/known-limitations.md`](docs/known-limitations.md). - ---- - -## Running the sample app - -The `sample` module is a Kotlin Multiplatform app (Android + iOS + Desktop) that demonstrates -all provider options available in Featured. - -### Default (DataStore) - -No extra configuration needed. The sample uses `defaultLocalProvider(context)` from -`:featured-platform`, which returns a `DataStoreConfigValueProvider` on Android. Flag overrides -written via the debug UI persist across app restarts. - -```bash -./gradlew :sample:assembleDebug -``` - -### SharedPreferences provider - -To see how `SharedPreferencesProviderConfig` is wired up, look at `buildConfigValues()` in -`SampleApplication.kt`. Swap the commented-out `localProvider` assignment for the active one. - -### Running with Firebase Remote Config - -Firebase Remote Config requires a `google-services.json` file from the Firebase console. - -1. Create a Firebase project at [console.firebase.google.com](https://console.firebase.google.com). -2. Register the Android app with package name `dev.androidbroadcast.featured`. -3. Download `google-services.json` and place it at `sample/google-services.json`. -4. Build the sample with the `hasFirebase` flag: - -```bash -./gradlew :sample:assembleDebug -PhasFirebase=true -``` +## Documentation -The build system detects `sample/google-services.json` automatically, so step 4 can also be -run without `-PhasFirebase=true` once the file is present. +Full documentation lives in the [Wiki](https://github.com/AndroidBroadcast/Featured/wiki): -5. In `SampleApplication.kt`, uncomment the `FirebaseConfigValueProvider` lines inside - `buildConfigValues()` and rebuild. +- [Getting Started](https://github.com/AndroidBroadcast/Featured/wiki/Getting-Started) +- [Installation](https://github.com/AndroidBroadcast/Featured/wiki/Installation) +- [Providers](https://github.com/AndroidBroadcast/Featured/wiki/Providers) +- [Release Optimization (DCE)](https://github.com/AndroidBroadcast/Featured/wiki/Release-Optimization) — how flags get stripped from release binaries +- [iOS Usage](https://github.com/AndroidBroadcast/Featured/wiki/iOS-Usage) +- [Best Practices](https://github.com/AndroidBroadcast/Featured/wiki/Best-Practices) -> **Note:** `google-services.json` is excluded from version control (`.gitignore`). Never commit -> credentials to the repository. +## Contributing ---- +See [CONTRIBUTING.md](CONTRIBUTING.md). -## API reference +## Security -Full KDoc-generated API reference is published to GitHub Pages: +See [SECURITY.md](SECURITY.md). -**[https://androidbroadcast.github.io/Featured/](https://androidbroadcast.github.io/Featured/)** +## License -Documentation is regenerated on every merge to `main`. +MIT — see [LICENSE](LICENSE). diff --git a/docs/api/index.md b/docs/api/index.md deleted file mode 100644 index e63d523..0000000 --- a/docs/api/index.md +++ /dev/null @@ -1,152 +0,0 @@ -# API Reference - -The full KDoc-generated API reference is built by [Dokka](https://kotlinlang.org/docs/dokka-introduction.html) and published automatically to GitHub Pages on every release. - -**[Browse the API reference →](https://androidbroadcast.github.io/Featured/api/)** - ---- - -## Core types - -### `ConfigParam` - -Declares a named, typed configuration key with a default value. - -```kotlin -ConfigParam( - key: String, - defaultValue: T, - description: String = "", - category: String = "", -) -``` - -### `ConfigValue` - -Wraps a `ConfigParam` with its resolved value and the source that provided it. - -```kotlin -data class ConfigValue( - val param: ConfigParam, - val value: T, - val source: Source, -) { - enum class Source { DEFAULT, LOCAL, REMOTE } -} -``` - -### `ConfigValues` - -Container that composes local and remote providers and exposes flag values reactively. - -```kotlin -ConfigValues( - localProvider: LocalConfigValueProvider? = null, - remoteProvider: RemoteConfigValueProvider? = null, -) -``` - -At least one provider must be non-null (enforced at construction time). - -**Key methods:** - -| Method | Description | -|--------|-------------| -| `getValue(param)` | Suspend: resolve current value | -| `observe(param)` | `Flow>` — emits on every change | -| `observeValue(param)` | `Flow` — emits raw values only | -| `asStateFlow(param, scope)` | Convert to `StateFlow` | -| `override(param, value)` | Write a local override | -| `resetOverride(param)` | Remove local override | -| `fetch()` | Trigger remote provider fetch and activate | - -### `LocalConfigValueProvider` - -Interface for writable, observable local storage. - -```kotlin -interface LocalConfigValueProvider { - suspend fun getValue(param: ConfigParam): ConfigValue? - fun observe(param: ConfigParam): Flow?> - suspend fun setValue(param: ConfigParam, value: T) - suspend fun removeValue(param: ConfigParam) -} -``` - -### `RemoteConfigValueProvider` - -Interface for fetch-based remote configuration. - -```kotlin -interface RemoteConfigValueProvider { - suspend fun fetch() - suspend fun getValue(param: ConfigParam): ConfigValue? - fun observe(param: ConfigParam): Flow?> -} -``` - ---- - -## Gradle DSL - -Flags are declared in `build.gradle.kts` using the `featured { }` extension block provided by the `dev.androidbroadcast.featured` Gradle plugin. - -```kotlin title="build.gradle.kts" -featured { - localFlags { - boolean("dark_mode", default = false) { category = "UI"; expiresAt = "2026-06-01" } - int("max_retries", default = 3) - } - remoteFlags { - boolean("promo_banner", default = false) { description = "Show promo banner" } - string("api_url", default = "https://api.example.com") - } -} -``` - -### Generated types - -The plugin generates: - -| Generated type | Description | -|---|---| -| `internal object GeneratedLocalFlags` | Typed `ConfigParam` properties for every local flag | -| `internal object GeneratedRemoteFlags` | Typed `ConfigParam` properties for every remote flag | -| Extension functions on `ConfigValues` | Local boolean flag → `fun ConfigValues.isEnabled(): Boolean`; local non-boolean → `fun ConfigValues.get(): T`; remote → `fun ConfigValues.get(): ConfigValue` | - -### Key tasks - -| Task | Description | -|---|---| -| `resolveFeatureFlags` | Resolves DSL-declared flags; runs before all code-generation tasks | -| `generateConfigParam` | Generates `GeneratedLocalFlags` and `GeneratedRemoteFlags` objects | -| `generateFlagRegistrar` | Generates flag registrar for the debug UI | -| `generateFeaturedProguardRules` | Generates per-function R8 `-assumevalues` rules for local boolean flags | -| `generateIosConstVal` | Generates `expect`/`actual const val` for local flags (iOS) | -| `generateXcconfig` | Generates xcconfig with `DISABLE_*` conditions for local boolean flags | -| `scanAllLocalFlags` | Aggregator task — collects flags across all modules | - ---- - -## Compose extensions (featured-compose) - -### `ConfigValues.collectAsState` - -```kotlin -@Composable -fun ConfigValues.collectAsState(param: ConfigParam): State -``` - -Collects the current and future values of `param` as Compose `State`. - -### `LocalConfigValues` - -```kotlin -val LocalConfigValues: ProvidableCompositionLocal -``` - -Composition local for providing a `ConfigValues` instance through the composition tree. - ---- - -The generated Dokka HTML output lives at `build/dokka/htmlMultiModule/` and is deployed to the `api/` path on GitHub Pages. diff --git a/docs/changelog.md b/docs/changelog.md deleted file mode 100644 index e14a179..0000000 --- a/docs/changelog.md +++ /dev/null @@ -1,39 +0,0 @@ -# Changelog - -All notable changes to Featured are documented here. - -For the full release history with diff links, see the -[GitHub Releases page](https://github.com/AndroidBroadcast/Featured/releases). - ---- - -## Unreleased - -_Changes on `main` not yet tagged for release._ - -### Changed -- Renamed the Gradle ProGuard/R8 generation task from `generateProguardRules` to - `generateFeaturedProguardRules` to avoid task-name clashes with consumer scripts. - Migration: update any CI/build scripts that invoke `generateProguardRules` to use - the new name. The old task name is no longer registered. (#190) - ---- - -## Contributing a changelog entry - -When opening a pull request, add a brief entry under **Unreleased** describing your change. -Use one of these categories: - -- **Added** — new public API or feature -- **Changed** — changes to existing behaviour -- **Deprecated** — soon-to-be-removed features -- **Removed** — removed features or APIs -- **Fixed** — bug fixes -- **Security** — vulnerability fixes - -Format: - -```markdown -### Added -- `ConfigValues.newMethod(param)` — short description (#PR) -``` diff --git a/docs/getting-started.md b/docs/getting-started.md deleted file mode 100644 index 9f19e6d..0000000 --- a/docs/getting-started.md +++ /dev/null @@ -1,129 +0,0 @@ -# Getting Started - -This page gets you from zero to a working feature flag in about 5 minutes. - -## Installation - -### Gradle version catalog - -Add the BOM to manage all module versions from a single place, then declare only the artifacts you need. - -```kotlin title="settings.gradle.kts" -dependencyResolutionManagement { - repositories { - mavenCentral() - google() - } -} -``` - -```kotlin title="build.gradle.kts" -plugins { - id("dev.androidbroadcast.featured") version "" -} - -dependencies { - implementation(platform("dev.androidbroadcast.featured:featured-bom:")) - - // Core runtime — always required - implementation("dev.androidbroadcast.featured:featured-core") - - // Optional modules — add only what you use - implementation("dev.androidbroadcast.featured:featured-compose") // Compose extensions - debugImplementation("dev.androidbroadcast.featured:featured-registry") // Flag registry for debug UI - debugImplementation("dev.androidbroadcast.featured:featured-debug-ui") // Debug screen - - // Local persistence providers — pick one (or both) - implementation("dev.androidbroadcast.featured:featured-datastore-provider") - implementation("dev.androidbroadcast.featured:featured-sharedpreferences-provider") - - // Remote provider - implementation("dev.androidbroadcast.featured:featured-firebase-provider") -} -``` - -!!! note - The Gradle plugin ID is `dev.androidbroadcast.featured`. It is also published to Maven Central under the artifact `dev.androidbroadcast.featured:featured-gradle-plugin`. - -### iOS — Swift Package Manager - -Add the package in Xcode (**File › Add Package Dependencies**) or in `Package.swift`: - -```swift -.package( - url: "https://github.com/AndroidBroadcast/Featured", - from: "" -) -``` - -Then add `FeaturedCore` as a target dependency: - -```swift -.target( - name: "MyApp", - dependencies: [ - .product(name: "FeaturedCore", package: "Featured") - ] -) -``` - -## Step 1 — Declare a flag - -Declare flags in `build.gradle.kts` using the `featured { }` DSL block. The plugin generates typed helpers automatically. - -```kotlin title="build.gradle.kts" -featured { - localFlags { - boolean("new_checkout", default = false) { - description = "Enable the new checkout flow" - category = "Checkout" - } - int("max_cart_items", default = 10) { - description = "Maximum items allowed in cart" - } - } - remoteFlags { - boolean("promo_banner", default = false) { - description = "Show promo banner" - } - } -} -``` - -The plugin generates `internal object GeneratedLocalFlags` and `internal object GeneratedRemoteFlags` with typed `ConfigParam` properties, and public extension functions on `ConfigValues` — for example `fun ConfigValues.isNewCheckoutEnabled(): Boolean`, `fun ConfigValues.getMaxCartItems(): Int`, and `fun ConfigValues.getPromoBanner(): ConfigValue`. - -## Step 2 — Create a `ConfigValues` instance - -Wire up providers once, typically in your dependency injection setup or `Application.onCreate`. - -```kotlin title="Android" -val configValues = ConfigValues( - localProvider = DataStoreConfigValueProvider(preferencesDataStore), - remoteProvider = FirebaseConfigValueProvider(), -) -``` - -`ConfigValues` requires at least one provider. Both `localProvider` and `remoteProvider` are optional individually, but at least one must be non-null. - -## Step 3 — Read a flag value - -Use the generated extension functions on `ConfigValues`: - -```kotlin -// Local boolean flag — returns Boolean directly -val isEnabled: Boolean = configValues.isNewCheckoutEnabled() - -// Local non-boolean flag — returns the value directly -val maxItems: Int = configValues.getMaxCartItems() - -// Remote flag — returns ConfigValue to expose source information -val promo: ConfigValue = configValues.getPromoBanner() -val source: ConfigValue.Source = promo.source // DEFAULT, LOCAL, or REMOTE -``` - -## Next steps - -- [Android guide](guides/android.md) — DataStore, Compose integration, and the debug UI -- [iOS guide](guides/ios.md) — Swift interop and dead-code elimination -- [Providers](guides/providers.md) — all built-in providers in detail -- [Best practices](guides/best-practices.md) — multi-module setup and testing diff --git a/docs/guides/android.md b/docs/guides/android.md deleted file mode 100644 index 001d6b4..0000000 --- a/docs/guides/android.md +++ /dev/null @@ -1,300 +0,0 @@ -# Android Integration Guide - -This guide walks you through integrating Featured into an Android project from scratch — from adding Gradle dependencies to using flags in a ViewModel with Compose and Firebase Remote Config. - -## 1. Add Gradle dependencies - -Apply the Featured Gradle plugin and declare the artifacts you need. The BOM manages all module versions from a single place. - -```kotlin title="build.gradle.kts" -plugins { - id("dev.androidbroadcast.featured") version "" -} - -dependencies { - implementation(platform("dev.androidbroadcast.featured:featured-bom:")) - - // Core runtime — always required - implementation("dev.androidbroadcast.featured:core") - - // Local persistence — pick one (or both) - implementation("dev.androidbroadcast.featured:datastore-provider") - implementation("dev.androidbroadcast.featured:sharedpreferences-provider") - - // Remote config - implementation("dev.androidbroadcast.featured:firebase-provider") - - // Compose extensions - implementation("dev.androidbroadcast.featured:featured-compose") - - // Debug UI — debug builds only - debugImplementation("dev.androidbroadcast.featured:featured-registry") - debugImplementation("dev.androidbroadcast.featured:featured-debug-ui") -} -``` - -!!! note - The Gradle plugin ID is `dev.androidbroadcast.featured`. It generates ProGuard / R8 rules and xcconfig files automatically when you build. - -## 2. Declare flags - -Declare flags in `build.gradle.kts` using the `featured { }` DSL block. The plugin generates typed helpers automatically. - -```kotlin title="build.gradle.kts" -featured { - localFlags { - boolean("new_checkout", default = false) { - description = "Enable the new checkout flow" - } - int("max_cart_items", default = 10) - } -} -``` - -The plugin generates `internal object GeneratedLocalFlags` with typed `ConfigParam` properties and public extension functions on `ConfigValues` such as `fun ConfigValues.isNewCheckoutEnabled(): Boolean` and `fun ConfigValues.getMaxCartItems(): Int`. - -## 3. Initialize `ConfigValues` in `Application.onCreate` - -Create a single `ConfigValues` instance and call `initialize()` before the app serves any screen. `initialize()` triggers the remote provider's activation step (for Firebase: activates fetched values). - -### With DataStore (recommended) - -```kotlin title="MyApplication.kt" -import androidx.datastore.preferences.preferencesDataStore -import dev.androidbroadcast.featured.ConfigValues -import dev.androidbroadcast.featured.datastore.DataStoreConfigValueProvider -import dev.androidbroadcast.featured.firebase.FirebaseConfigValueProvider - -val Context.featureFlagDataStore by preferencesDataStore(name = "feature_flags") - -class MyApplication : Application() { - - lateinit var configValues: ConfigValues - - override fun onCreate() { - super.onCreate() - - val localProvider = DataStoreConfigValueProvider(featureFlagDataStore) - val remoteProvider = FirebaseConfigValueProvider() - - configValues = ConfigValues( - localProvider = localProvider, - remoteProvider = remoteProvider, - ) - - // Activate previously fetched remote values and trigger a background fetch - lifecycleScope.launch { - configValues.initialize() - configValues.fetch() - } - } -} -``` - -### With SharedPreferences - -```kotlin -import android.content.Context -import dev.androidbroadcast.featured.ConfigValues -import dev.androidbroadcast.featured.sharedpreferences.SharedPreferencesProviderConfig - -val prefs = context.getSharedPreferences("feature_flags", Context.MODE_PRIVATE) -val localProvider = SharedPreferencesProviderConfig(prefs) - -val configValues = ConfigValues(localProvider = localProvider) -``` - -## 4. Use in ViewModel with `observe` - -Expose flag state as `StateFlow` so Compose (or view-based UIs) can collect it: - -```kotlin -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dev.androidbroadcast.featured.ConfigValues -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn - -class CheckoutViewModel( - private val configValues: ConfigValues, -) : ViewModel() { - - // StateFlow of the raw Boolean — reacts to both local and remote changes - val isNewCheckoutEnabled: StateFlow = - configValues.observe(FeatureFlags.newCheckout) - .map { it.value } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = FeatureFlags.newCheckout.defaultValue, - ) -} -``` - -Or use the built-in `asStateFlow` extension: - -```kotlin -val isNewCheckoutEnabled: StateFlow = configValues.asStateFlow( - param = FeatureFlags.newCheckout, - scope = viewModelScope, -) -``` - -## 5. Compose integration - -Add the `featured-compose` artifact (already listed in step 1). - -### Collecting flag state in a Composable - -```kotlin -@Composable -fun CheckoutScreen(configValues: ConfigValues) { - val isEnabled: State = configValues.collectAsState(FeatureFlags.newCheckout) - - if (isEnabled.value) { - NewCheckoutContent() - } else { - LegacyCheckoutContent() - } -} -``` - -### Providing `ConfigValues` via CompositionLocal - -```kotlin -// In your root composable -CompositionLocalProvider(LocalConfigValues provides configValues) { - AppContent() -} - -// Anywhere below -@Composable -fun SomeDeepComponent() { - val configValues = LocalConfigValues.current - val enabled by configValues.collectAsState(FeatureFlags.newCheckout) - // … -} -``` - -## 6. Add Firebase Remote Config provider - -`FirebaseConfigValueProvider` wraps the Firebase SDK. Add the dependency (step 1) and pass it as `remoteProvider`: - -```kotlin -import com.google.firebase.remoteconfig.FirebaseRemoteConfig -import dev.androidbroadcast.featured.firebase.FirebaseConfigValueProvider - -// Use the default singleton (recommended) -val remoteProvider = FirebaseConfigValueProvider() - -// Or supply a custom instance with non-default fetch interval -val remoteConfig = FirebaseRemoteConfig.getInstance().also { config -> - config.setConfigSettingsAsync( - com.google.firebase.remoteconfig.remoteConfigSettings { - minimumFetchIntervalInSeconds = 3600 - } - ) -} -val remoteProvider = FirebaseConfigValueProvider(remoteConfig) - -val configValues = ConfigValues( - localProvider = localProvider, - remoteProvider = remoteProvider, -) -``` - -After `configValues.initialize()`, remote values fetched during the previous session become active. Call `configValues.fetch()` to trigger a fresh fetch and activate the result. - -## 7. Debug UI — flag override screen - -`featured-debug-ui` ships a Compose screen that lists every registered flag with its current value and source, and lets you toggle or override values at runtime. - -Add debug artifacts only to debug builds (step 1 already shows `debugImplementation`). - -### Register flags - -```kotlin -import dev.androidbroadcast.featured.registry.FlagRegistry - -// Call once in Application.onCreate (or your DI module) — debug builds only -if (BuildConfig.DEBUG) { - FlagRegistry.register(FeatureFlags.newCheckout) - FlagRegistry.register(FeatureFlags.maxCartItems) -} -``` - -### Show the debug screen - -```kotlin -import dev.androidbroadcast.featured.debugui.FeatureFlagsDebugScreen - -@Composable -fun DebugMenuScreen(configValues: ConfigValues) { - FeatureFlagsDebugScreen(configValues = configValues) -} -``` - -Navigate to this screen from your in-app debug menu (a drawer, a shake gesture, or a long-press on the app icon shortcut). - -## 8. ProGuard / R8 setup - -The Gradle plugin generates per-function `-assumevalues` rules for the generated extension functions of every local boolean flag with `default = false`. These rules instruct R8 to treat the flag as a compile-time constant `false`, removing all guarded code from release APKs. Remote flags are excluded since their values are dynamic. - -The task runs automatically when you build a release variant. To run it manually: - -```bash -./gradlew :app:generateFeaturedProguardRules -``` - -Output: `app/build/featured/proguard-featured.pro` - -No extra configuration is needed — the plugin wires the output into the R8 pipeline automatically. - -## Overriding and resetting at runtime - -```kotlin -// Write a local override — survives remote fetches -configValues.override(FeatureFlags.newCheckout, true) - -// Revert to the provider's stored or default value -configValues.resetOverride(FeatureFlags.newCheckout) - -// Clear all local overrides -configValues.clearOverrides() -``` - -## Reading flags - -### One-shot read - -```kotlin -val configValue: ConfigValue = configValues.getValue(FeatureFlags.newCheckout) -if (configValue.value) { - // feature is active -} -val source = configValue.source // DEFAULT, LOCAL, or REMOTE -``` - -### Reactive observation (Flow) - -```kotlin -// Emits immediately with the current value, then on every change -configValues.observe(FeatureFlags.newCheckout) - .collect { configValue -> - println("new_checkout = ${configValue.value} (source: ${configValue.source})") - } - -// Convenience: emit only the raw value, not the ConfigValue wrapper -configValues.observe(FeatureFlags.newCheckout) - .map { it.value } - .collect { isEnabled: Boolean -> /* … */ } -``` - -## Next steps - -- [iOS guide](ios.md) — Swift interop and dead-code elimination -- [JVM guide](jvm.md) — server and desktop integration -- [Providers](providers.md) — all built-in providers in detail -- [Best practices](best-practices.md) — multi-module setup and testing diff --git a/docs/guides/best-practices.md b/docs/guides/best-practices.md deleted file mode 100644 index 5c60b97..0000000 --- a/docs/guides/best-practices.md +++ /dev/null @@ -1,303 +0,0 @@ -# Best Practices - -## Flag lifecycle - -Feature flags are temporary by design. Every flag should progress through three stages and then be deleted. - -``` -Draft → Rollout → Cleanup -``` - -### 1. Draft — introduce the flag - -Declare the flag in `build.gradle.kts` using the `featured { }` DSL block and set an expiry date using `expiresAt` so stale flags surface automatically in CI. Use `localFlags { }` for flags that will be deleted once the rollout is complete, and `remoteFlags { }` for flags that will be permanently controlled from the server. - -```kotlin -// GOOD: expiry date set, snake_case key, module-prefixed -featured { - localFlags { - boolean("checkout_new_flow", default = false) { - description = "Enable the redesigned single-page checkout" - category = "checkout" - expiresAt = "2026-09-01" - } - } -} -``` - -Guard every entry point — UI composition roots **and** deep-link handlers — behind the generated extension function. Keep the default `false` so the feature is off until you explicitly enable it. - -```kotlin -// Guard a Compose entry point -val isNewCheckout by configValues.collectAsState(GeneratedLocalFlags.checkoutNewFlow) - -if (isNewCheckout) { - NewCheckoutScreen() -} else { - LegacyCheckoutScreen() -} -``` - -### 2. Rollout — enable remotely - -Use a `RemoteConfigValueProvider` (e.g. Firebase Remote Config) to enable the flag for a growing percentage of users. Remote values automatically override local defaults — no code change required. - -Declare the flag in `remoteFlags { }` when it is intended to be permanently controlled from the server (A/B experiments, promotional banners). Use `localFlags { }` for flags that will eventually be deleted once the rollout is complete. - -```kotlin -// Permanent remote-controlled flag -featured { - remoteFlags { - boolean("promo_banner_enabled", default = false) { - description = "Show a promotional banner (remote-controlled)" - category = "promotions" - } - } -} -``` - -### 3. Cleanup — delete the flag - -Once the feature is fully rolled out and validated: - -1. Remove the flag from the `featured { }` DSL block in `build.gradle.kts`. -2. Delete all usages of the generated extension function and any guarding `if` blocks — keep only the new-path code. -3. Remove the corresponding key from your remote configuration backend. -4. Regenerate platform artefacts: - -```bash -./gradlew generateFeaturedProguardRules # keep Android R8 rules in sync -./gradlew generateXcconfig # keep iOS xcconfig in sync -``` - -The `ExpiredFeatureFlagRule` Detekt rule will warn at build time for any flag whose `@ExpiresAt` date has passed, preventing flags from silently accumulating. - ---- - -## Naming conventions - -| What | Convention | Example | -|---|---|---| -| `ConfigParam` key | `snake_case`, module-prefixed | `checkout_new_flow` | -| Kotlin property | `camelCase` matching the key | `newCheckoutFlow` | -| Firebase / remote key | Same `snake_case` as the key | `checkout_new_flow` | - -Group related flags with a shared prefix (`checkout_*`, `payments_*`). This keeps the debug UI readable and makes it obvious which team owns each flag. - -```kotlin -// GOOD -val newCheckoutFlow: ConfigParam = ConfigParam(key = "checkout_new_flow", ...) -val checkoutPaymentV2: ConfigParam = ConfigParam(key = "checkout_payment_v2", ...) - -// BAD — no module prefix, impossible to attribute ownership -val enabled: ConfigParam = ConfigParam(key = "new_flow", ...) -``` - ---- - -## Multi-module patterns - -### One FlagRegistry per module - -Each feature module declares its own object holding its `ConfigParam` instances. The module does not create `ConfigValues` — that is the app module's responsibility. - -```kotlin title=":feature:checkout/build.gradle.kts" -featured { - localFlags { - boolean("checkout_new_flow", default = false) { - description = "Enable the redesigned checkout flow" - category = "checkout" - expiresAt = "2026-09-01" - } - boolean("checkout_payment_v2", default = false) { - description = "Enable Payment V2 during checkout" - category = "checkout" - expiresAt = "2026-09-01" - } - } -} -``` - -```kotlin title=":feature:promotions/build.gradle.kts" -featured { - remoteFlags { - boolean("promo_banner_enabled", default = false) { - description = "Show a promotional banner (remote-controlled)" - category = "promotions" - } - } -} -``` - -### App-level aggregation - -The app module owns the single `ConfigValues` instance and wires together providers. Feature modules receive `ConfigValues` via dependency injection. - -```kotlin title=":app/src/main/kotlin/.../AppModule.kt (Hilt example)" -@Module -@InstallIn(SingletonComponent::class) -object AppModule { - - @Provides - @Singleton - fun provideConfigValues( - @ApplicationContext context: Context, - ): ConfigValues = ConfigValues( - localProvider = DataStoreConfigValueProvider(context.featureFlagsDataStore), - remoteProvider = FirebaseConfigValueProvider(), - ) -} -``` - -Feature modules consume `ConfigValues` without knowing how providers are wired: - -```kotlin title=":feature:checkout/src/.../CheckoutViewModel.kt" -@HiltViewModel -class CheckoutViewModel @Inject constructor( - private val configValues: ConfigValues, -) : ViewModel() { - - // isCheckoutNewFlowEnabled() is the generated extension function from the DSL declaration - val isNewFlowEnabled: StateFlow = configValues.asStateFlow( - param = GeneratedLocalFlags.checkoutNewFlow, - scope = viewModelScope, - ) -} -``` - - ---- - -## Testing - -Use `fakeConfigValues` from the `featured-testing` artifact — it is fully synchronous, has no external dependencies, and supports both initial values and mid-test overrides. - -```kotlin -import dev.androidbroadcast.featured.testing.fakeConfigValues - -class CheckoutViewModelTest { - - @Test - fun `new checkout flow enabled shows new UI`() = runTest { - val configValues = fakeConfigValues { - set(CheckoutFlags.newFlow, true) - } - val vm = CheckoutViewModel(configValues) - - assertEquals(true, vm.isNewFlowEnabled.value) - } - - @Test - fun `new checkout flow disabled shows legacy UI`() = runTest { - // No override — default value (false) applies - val configValues = fakeConfigValues() - val vm = CheckoutViewModel(configValues) - - assertEquals(false, vm.isNewFlowEnabled.value) - } - - @Test - fun `reactive update when flag toggled mid-test`() = runTest { - val configValues = fakeConfigValues { - set(CheckoutFlags.newFlow, false) - } - val vm = CheckoutViewModel(configValues) - - // Simulate a remote change arriving during the session - configValues.override(CheckoutFlags.newFlow, true) - - assertEquals(true, vm.isNewFlowEnabled.value) - } -} -``` - -Never use real providers (`FirebaseConfigValueProvider`, `DataStoreConfigValueProvider`) in unit tests — they require Android or network context and make tests non-deterministic. - ---- - -## Anti-patterns - -### Flags that never get cleaned up - -Flags are temporary scaffolding, not permanent configuration. Without an expiry date they accumulate silently. - -```kotlin -// BAD — no expiresAt, will never prompt cleanup -featured { - localFlags { - boolean("checkout_new_flow", default = false) - } -} - -// GOOD — expiresAt triggers the ExpiredFeatureFlagRule Detekt warning on the deadline -featured { - localFlags { - boolean("checkout_new_flow", default = false) { - expiresAt = "2026-09-01" - } - } -} -``` - -### Using flags for configuration values - -`localFlags` boolean entries are for feature toggles that will be deleted. Long-lived configuration values (thresholds, URLs, strings) should be declared in `remoteFlags { }` — they are not subject to the cleanup lifecycle. - -```kotlin -// BAD — a URL is not a temporary feature flag; it will never be "cleaned up" -featured { - localFlags { - string("api_base_url", default = "https://api.example.com") - } -} - -// GOOD — permanent remote config value, not a temporary flag -featured { - remoteFlags { - string("api_base_url", default = "https://api.example.com") - } -} -``` - -### Hardcoding flag values in production code - -Hardcoding `true` or `false` instead of reading from `ConfigValues` defeats the purpose of the system. The `HardcodedFlagValueRule` Detekt rule catches direct accesses to `ConfigParam.defaultValue` in production code. - -```kotlin -// BAD — bypasses the provider stack entirely -if (FeatureFlags.newCheckout.defaultValue) { ... } - -// GOOD — reads the live value through ConfigValues -if (configValues.getValue(FeatureFlags.newCheckout)) { ... } -``` - -### Testing with real providers - -```kotlin -// BAD — requires Firebase SDK and network; non-deterministic -val configValues = ConfigValues(remoteProvider = FirebaseConfigValueProvider()) - -// GOOD — deterministic, no dependencies -val configValues = fakeConfigValues { set(CheckoutFlags.newFlow, true) } -``` - ---- - -## Automated enforcement (Detekt rules) - -Add the `featured-detekt-rules` dependency to your Detekt configuration to enforce the above patterns automatically at build time: - -| Rule | What it catches | -|---|---| -| `ExpiredFeatureFlagRule` | Flags whose `expiresAt` date is in the past | -| `HardcodedFlagValueRule` | Direct access to `ConfigParam.defaultValue` in production code | - -With these rules enabled, the lifecycle contract is enforced by CI rather than code review alone. - ---- - -## Security - -- Never store secrets (API keys, tokens) as `ConfigParam` values. Flags are for feature toggles and configuration, not credentials. -- Remote Config values are not end-to-end encrypted. Do not use them to gate security-critical behaviour. -- Default values are compiled into the binary. Do not rely on a flag's default being secret. diff --git a/docs/guides/ios.md b/docs/guides/ios.md deleted file mode 100644 index ca05363..0000000 --- a/docs/guides/ios.md +++ /dev/null @@ -1,259 +0,0 @@ -# iOS Integration Guide - -Featured exposes its Kotlin API to Swift via [SKIE](https://skie.touchlab.co/), which bridges coroutines, sealed classes, and default arguments automatically. - -## 1. Swift Package Manager setup - -Add the package in Xcode (**File › Add Package Dependencies**) or in `Package.swift`: - -```swift -.package( - url: "https://github.com/AndroidBroadcast/Featured", - from: "" -) -``` - -Then add `FeaturedCore` as a target dependency: - -```swift -.target( - name: "MyApp", - dependencies: [ - .product(name: "FeaturedCore", package: "Featured") - ] -) -``` - -## 2. Declare flags in the shared module - -Declare flags in the shared module's `build.gradle.kts` using the `featured { }` DSL block. The plugin generates typed helpers automatically. - -```kotlin title="shared/build.gradle.kts" -featured { - localFlags { - boolean("new_checkout", default = false) { - description = "Enable the new checkout flow" - } - } -} -``` - -The plugin generates `internal object GeneratedLocalFlags` with typed `ConfigParam` properties and public extension functions on `ConfigValues` such as `fun ConfigValues.isNewCheckoutEnabled(): Boolean`. These generated types are exported to Swift via the KMP framework. - -## 3. Initialize `ConfigValues` in Swift - -Call `initialize()` at app launch (before serving any screen) to activate values fetched during the previous session. Then trigger a background fetch so the next launch sees fresh values. - -```swift -import FeaturedCore - -@main -struct MyApp: App { - @StateObject private var appState = AppState() - - var body: some Scene { - WindowGroup { - ContentView() - .task { await appState.setup() } - } - } -} - -@MainActor -class AppState: ObservableObject { - let configValues: ConfigValues - - init() { - configValues = ConfigValues( - localProvider: nil, // add a provider if needed - remoteProvider: nil, // e.g. FirebaseConfigValueProvider() - onProviderError: { error in print("Featured error: \(error)") } - ) - } - - func setup() async { - do { - try await configValues.initialize() - try await configValues.fetch() - } catch { - print("Featured setup error: \(error)") - } - } -} -``` - -## 4. Reading flags in Swift - -The SKIE bridge makes the Kotlin `ConfigValues` API available in Swift with async/await and `AsyncStream`. - -```swift -import FeaturedCore - -// One-shot async read -let configValue = try await configValues.getValue(param: FeatureFlags.shared.newCheckout) -let isEnabled: Bool = configValue.value - -// AsyncStream — use in a Task or async for-await loop -for await configValue in configValues.observe(param: FeatureFlags.shared.newCheckout) { - updateUI(configValue.value) -} -``` - -## 5. Combine publisher - -SKIE wraps the Kotlin `Flow` as an `AsyncStream`. Combine publishers can be built on top using `AsyncStream.publisher`: - -```swift -import Combine -import FeaturedCore - -class CheckoutViewModel: ObservableObject { - @Published var isNewCheckoutEnabled: Bool = false - - private var cancellables = Set() - private let configValues: ConfigValues - - init(configValues: ConfigValues) { - self.configValues = configValues - } - - func startObserving() { - // Bridge AsyncStream → Combine publisher - let stream = configValues.observe(param: FeatureFlags.shared.newCheckout) - - Task { - for await configValue in stream { - await MainActor.run { - self.isNewCheckoutEnabled = configValue.value - } - } - } - } -} -``` - -Alternatively, use `publisher(for:)` if your Swift wrapper exposes it: - -```swift -featureFlags.publisher(for: newCheckoutFlag) - .receive(on: DispatchQueue.main) - .sink { isEnabled in updateUI(isEnabled) } - .store(in: &cancellables) -``` - -## 6. SwiftUI integration - -Collect the flag value in a `@StateObject` ViewModel and bind it to the view: - -```swift -struct CheckoutScreen: View { - @StateObject private var viewModel: CheckoutViewModel - - var body: some View { - Group { - if viewModel.isNewCheckoutEnabled { - NewCheckoutView() - } else { - LegacyCheckoutView() - } - } - .task { viewModel.startObserving() } - } -} -``` - -## 7. Swift dead-code elimination via xcconfig - -The Gradle plugin generates an xcconfig file that feeds Swift compilation conditions into Xcode. For every local boolean flag declared in `featured { localFlags { } }` with `default = false`, a `DISABLE_` condition is generated. - -### Key transformation - -| Kotlin flag key | Generated condition | -|-------------------|--------------------------| -| `new_checkout` | `DISABLE_NEW_CHECKOUT` | -| `experimentalUi` | `DISABLE_EXPERIMENTAL_UI`| - -### Step 1 — Generate the xcconfig - -```bash -./gradlew :shared:generateXcconfig -``` - -Output: `shared/build/featured/FeatureFlags.generated.xcconfig` - -Example content: - -```xcconfig -# Auto-generated by featured-gradle-plugin — do not edit -SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) DISABLE_NEW_CHECKOUT DISABLE_EXPERIMENTAL_UI -``` - -### Step 2 — Make the file available to Xcode - -Copy or symlink the file to a stable path inside your Xcode project tree: - -```bash -# Copy (re-run after each generateXcconfig invocation) -cp shared/build/featured/FeatureFlags.generated.xcconfig \ - iosApp/Configuration/FeatureFlags.generated.xcconfig - -# Symlink (resolved automatically) -ln -sf ../../shared/build/featured/FeatureFlags.generated.xcconfig \ - iosApp/Configuration/FeatureFlags.generated.xcconfig -``` - -Add the generated file to `.gitignore` if you use the copy approach: - -```gitignore -iosApp/Configuration/FeatureFlags.generated.xcconfig -``` - -### Step 3 — Configure Xcode (one-time) - -1. Open your `.xcodeproj` in Xcode. -2. Select the project in the Navigator → **Info** tab → **Configurations**. -3. Expand the **Release** configuration. -4. Set the configuration file for your app target to `FeatureFlags.generated.xcconfig`. - -!!! tip - Only assign the xcconfig to **Release**. Debug builds intentionally omit it so every feature remains reachable during development. - -### Step 4 — Guard Swift entry points with `#if` - -```swift -// Entry point for the new checkout feature -#if !DISABLE_NEW_CHECKOUT -NewCheckoutButton() -#endif - -// Deep-link handler -#if !DISABLE_NEW_CHECKOUT -case .newCheckout: NewCheckoutCoordinator.start() -#endif - -// AppDelegate / SceneDelegate -#if !DISABLE_NEW_CHECKOUT -setupNewCheckoutObservers() -#endif -``` - -The Swift compiler removes the entire guarded block from Release binaries — zero runtime overhead. - -### Automate with a pre-build Run Script phase - -Add this script to your Xcode target's Build Phases (before Compile Sources). Set **Based on dependency analysis** to **off**: - -```bash -cd "${SRCROOT}/.." -./gradlew :shared:generateXcconfig --quiet -cp shared/build/featured/FeatureFlags.generated.xcconfig \ - iosApp/Configuration/FeatureFlags.generated.xcconfig -``` - -For more detail, see the full [iOS Integration Guide](../ios-integration.md). - -## Next steps - -- [Android guide](android.md) — DataStore, Compose integration, and the debug UI -- [JVM guide](jvm.md) — server and desktop integration -- [Providers](providers.md) — all built-in providers in detail diff --git a/docs/guides/jvm.md b/docs/guides/jvm.md deleted file mode 100644 index c1aaaa8..0000000 --- a/docs/guides/jvm.md +++ /dev/null @@ -1,260 +0,0 @@ -# JVM / Desktop Integration Guide - -Featured works on plain JVM targets (server, desktop, CLI) without any Android or iOS dependencies. - -## 1. Add Gradle dependencies - -```kotlin title="build.gradle.kts" -plugins { - id("dev.androidbroadcast.featured") version "" -} - -dependencies { - implementation(platform("dev.androidbroadcast.featured:featured-bom:")) - - // Core runtime — always required - implementation("dev.androidbroadcast.featured:core") - - // Persistent local provider backed by java.util.prefs.Preferences - implementation("dev.androidbroadcast.featured:javaprefs-provider") - - // Test helper — add to test scope only - testImplementation("dev.androidbroadcast.featured:featured-testing") -} -``` - -!!! note - The `javaprefs-provider` artifact is JVM-only. It does not pull in any Android or Apple platform dependencies. - -## 2. Declare flags - -Declare flags in `build.gradle.kts` using the `featured { }` DSL block. The plugin generates typed helpers automatically. - -```kotlin title="build.gradle.kts" -featured { - localFlags { - boolean("dark_mode", default = false) { - description = "Enable dark mode UI" - } - int("page_size", default = 20) { - description = "Number of items per page" - } - } -} -``` - -The plugin generates `internal object GeneratedLocalFlags` with typed `ConfigParam` properties and public extension functions on `ConfigValues` such as `fun ConfigValues.isDarkModeEnabled(): Boolean` and `fun ConfigValues.getPageSize(): Int`. - -## 3. Create `ConfigValues` with `JavaPreferencesConfigValueProvider` - -`JavaPreferencesConfigValueProvider` persists values using `java.util.prefs.Preferences`. Storage is OS-specific: the registry on Windows, a plist on macOS, and `~/.java` on Linux. Values survive process restarts automatically. - -```kotlin -import dev.androidbroadcast.featured.ConfigValues -import dev.androidbroadcast.featured.javaprefs.JavaPreferencesConfigValueProvider -import java.util.prefs.Preferences - -// Default: stores under the user root, node "featured" -val provider = JavaPreferencesConfigValueProvider() - -// Custom node — useful for isolating test data or multiple app instances -val provider = JavaPreferencesConfigValueProvider( - node = Preferences.userRoot().node("com/example/myapp/flags") -) - -val configValues = ConfigValues(localProvider = provider) -``` - -### Supporting custom types - -Built-in support covers `String`, `Int`, `Boolean`, `Float`, `Long`, and `Double`. Register a `TypeConverter` for any additional type before first use: - -```kotlin -import dev.androidbroadcast.featured.TypeConverter -import dev.androidbroadcast.featured.javaprefs.registerConverter - -enum class Theme { LIGHT, DARK, SYSTEM } - -provider.registerConverter( - TypeConverter( - fromString = { Theme.valueOf(it) }, - toString = { it.name }, - ) -) -``` - -## 4. Initialize and fetch (optional) - -On JVM there is typically no remote provider, so `initialize()` and `fetch()` are not required. If you wire a remote provider (e.g., a custom `RemoteConfigValueProvider` backed by a feature-flag service), call them once on startup: - -```kotlin -import kotlinx.coroutines.runBlocking - -runBlocking { - configValues.initialize() - configValues.fetch() -} -``` - -## 5. Read flags - -```kotlin -import kotlinx.coroutines.runBlocking - -// One-shot read — from a coroutine or runBlocking in scripts / tests -val value = runBlocking { configValues.getValue(FeatureFlags.darkMode) } -println("dark_mode = ${value.value} (source: ${value.source})") -// source is DEFAULT, LOCAL, or REMOTE -``` - -## 6. Reactive observation - -Featured uses Kotlin Coroutines' `Flow` for reactive updates on all platforms, including JVM: - -```kotlin -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.runBlocking - -runBlocking { - configValues.observe(FeatureFlags.darkMode).collect { configValue -> - println("dark_mode changed: ${configValue.value}") - } -} -``` - -In a long-lived server process, collect inside a `CoroutineScope` tied to the application lifecycle: - -```kotlin -applicationScope.launch { - configValues.observe(FeatureFlags.darkMode).collect { configValue -> - // Reconfigure the application when the flag changes - updateTheme(configValue.value) - } -} -``` - -## 7. Override and reset at runtime - -```kotlin -// Apply a local override — useful for admin overrides or staged rollouts -configValues.override(FeatureFlags.darkMode, true) - -// Reset to the stored or default value -configValues.resetOverride(FeatureFlags.darkMode) - -// Clear all local overrides -configValues.clearOverrides() -``` - -## 8. Testing with `FakeConfigValues` - -The `featured-testing` artifact provides `fakeConfigValues` — a suspend factory function that builds a `ConfigValues` backed by an in-memory provider. No real `Preferences` storage is involved. - -```kotlin -import dev.androidbroadcast.featured.testing.fakeConfigValues -import dev.androidbroadcast.featured.testing.fake -import kotlinx.coroutines.test.runTest - -class FeatureFlagTest { - - @Test - fun `new checkout is enabled when flag is on`() = runTest { - val configValues = fakeConfigValues { - set(FeatureFlags.newCheckout, true) - } - - val value = configValues.getValue(FeatureFlags.newCheckout) - assertTrue(value.value) - } - - @Test - fun `defaults are used when no override is set`() = runTest { - val configValues = fakeConfigValues() - - val value = configValues.getValue(FeatureFlags.darkMode) - assertEquals(FeatureFlags.darkMode.defaultValue, value.value) - } -} -``` - -You can also use the companion extension for a more idiomatic call site: - -```kotlin -import dev.androidbroadcast.featured.ConfigValues -import dev.androidbroadcast.featured.testing.fake - -val configValues = ConfigValues.fake { - set(FeatureFlags.pageSize, 50) -} -``` - -### Simulating mid-test flag changes - -`fakeConfigValues` returns a real `ConfigValues` instance — you can call `override` to simulate remote pushes or user-triggered overrides: - -```kotlin -@Test -fun `UI updates when flag changes at runtime`() = runTest { - val configValues = fakeConfigValues { - set(FeatureFlags.newCheckout, false) - } - - val collected = mutableListOf() - val job = launch { - configValues.observe(FeatureFlags.newCheckout).collect { collected.add(it.value) } - } - - configValues.override(FeatureFlags.newCheckout, true) - advanceUntilIdle() - - job.cancel() - assertEquals(listOf(false, true), collected) -} -``` - -## 9. Writing a custom provider - -Implement `LocalConfigValueProvider` to back flags with any storage (database, config file, etc.): - -```kotlin -import dev.androidbroadcast.featured.ConfigParam -import dev.androidbroadcast.featured.ConfigValue -import dev.androidbroadcast.featured.LocalConfigValueProvider -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow - -class PropertiesConfigValueProvider( - private val file: java.io.File, -) : LocalConfigValueProvider { - - private val props = java.util.Properties().also { - if (file.exists()) it.load(file.reader()) - } - - override suspend fun get(param: ConfigParam): ConfigValue? { - val raw = props.getProperty(param.key) ?: return null - @Suppress("UNCHECKED_CAST") - val value = raw as? T ?: return null - return ConfigValue(value, ConfigValue.Source.LOCAL) - } - - override fun observe(param: ConfigParam): Flow> = - MutableStateFlow(null) // simplified — add file watching for full reactivity - - override suspend fun set(param: ConfigParam, value: T) { - props.setProperty(param.key, value.toString()) - props.store(file.writer(), null) - } - - override suspend fun resetOverride(param: ConfigParam) { - props.remove(param.key) - props.store(file.writer(), null) - } -} -``` - -## Next steps - -- [Providers](providers.md) — all built-in providers in detail -- [Best practices](best-practices.md) — multi-module setup and testing -- [Android guide](android.md) — DataStore, Compose integration, and the debug UI diff --git a/docs/guides/providers.md b/docs/guides/providers.md deleted file mode 100644 index 12ce19b..0000000 --- a/docs/guides/providers.md +++ /dev/null @@ -1,344 +0,0 @@ -# Providers - -`ConfigValues` composes one optional local provider and one optional remote provider. At least one must be provided. - -``` -ConfigValues -├── LocalConfigValueProvider (optional, but at least one required) -└── RemoteConfigValueProvider (optional, but at least one required) -``` - -Remote values take precedence over local values when both are present for the same key. - ---- - -## Built-in local providers - -### InMemoryConfigValueProvider - -Stores overrides in a plain in-memory `Map`. No setup, no dependencies. - -**Use cases:** unit tests, Compose previews, ephemeral runtime overrides that do not need to survive process death. - -**Limitations:** values are lost when the process terminates. Not suitable for user-facing feature flag overrides that must persist across app restarts. - -```kotlin -val configValues = ConfigValues( - localProvider = InMemoryConfigValueProvider(), -) -``` - -Override and reset a value programmatically: - -```kotlin -val provider = InMemoryConfigValueProvider() -val configValues = ConfigValues(localProvider = provider) - -provider.set(DarkModeParam, true) // override -provider.resetOverride(DarkModeParam) // revert to default/remote -provider.clear() // remove all overrides (no Flow signal emitted) -``` - -`set` and `resetOverride` notify active `observe` flows immediately. `clear` does not emit change signals — use `resetOverride` per-param when reactive teardown is needed. - ---- - -### DataStoreConfigValueProvider - -Persists overrides to [Jetpack DataStore Preferences](https://developer.android.com/topic/libraries/architecture/datastore). Reactive: changes emit immediately via `Flow` without polling. - -**Supported types natively:** `String`, `Int`, `Long`, `Float`, `Double`, `Boolean`. - -**Custom types** (e.g. enums) require a registered `TypeConverter` — see [Custom types](#custom-types) below. - -**Dependency:** - -```kotlin -implementation("dev.androidbroadcast.featured:datastore-provider") -``` - -**Setup:** - -```kotlin -// Declare once per file, outside any function or class -private val Context.featureFlagsDataStore: DataStore - by preferencesDataStore(name = "feature_flags") - -val provider = DataStoreConfigValueProvider(context.featureFlagsDataStore) -val configValues = ConfigValues(localProvider = provider) -``` - -**Custom type (enum) example:** - -```kotlin -enum class CheckoutVariant { STANDARD, ONE_CLICK } - -provider.registerConverter(enumConverter()) -``` - -`registerConverter` must be called before the first `get` or `set` call for that type. - -**Persistence behaviour:** writes are performed via `DataStore.edit`, which is atomic and crash-safe. Active `observe` flows re-emit after each write. `clear()` removes all keys from the DataStore file and also causes observers to re-emit. - ---- - -### SharedPreferencesConfigValueProvider - -Android-only. Persists overrides to `SharedPreferences`. All reads and writes are dispatched on `Dispatchers.IO`. - -**Supported types:** `String`, `Int`, `Long`, `Float`, `Double`, `Boolean`. - -**Dependency:** - -```kotlin -implementation("dev.androidbroadcast.featured:sharedpreferences-provider") -``` - -**Setup:** - -```kotlin -val prefs = context.getSharedPreferences("feature_flags", Context.MODE_PRIVATE) - -val provider = SharedPreferencesProviderConfig(prefs) -val configValues = ConfigValues(localProvider = provider) -``` - -**Custom type (enum) example:** - -```kotlin -provider.registerConverter(enumConverter()) -``` - -**Additional context:** you can merge an extra `CoroutineContext` into the IO dispatcher used for all operations: - -```kotlin -val provider = SharedPreferencesProviderConfig(prefs, additionalContext = myContext) -``` - -Active `observe` flows receive updates on every `set`, `resetOverride`, or `remove` call for the observed key. Consecutive identical values are deduplicated via `distinctUntilChanged`. - -!!! note - Prefer `DataStoreConfigValueProvider` for new projects. `SharedPreferencesProviderConfig` exists for projects that already rely on `SharedPreferences` and want to avoid a migration. - ---- - -### NSUserDefaultsConfigValueProvider - -iOS-only. Persists overrides to [`NSUserDefaults`](https://developer.apple.com/documentation/foundation/nsuserdefaults). - -**Supported types:** `String`, `Int`, `Long`, `Float`, `Double`, `Boolean`. - -**Dependency:** - -```kotlin -implementation("dev.androidbroadcast.featured:nsuserdefaults-provider") -``` - -**Setup:** - -```kotlin -// Uses the standard user defaults -val provider = NSUserDefaultsConfigValueProvider() - -// Or use a named suite (recommended for app groups / extensions) -val provider = NSUserDefaultsConfigValueProvider(suiteName = "com.example.app.flags") - -val configValues = ConfigValues(localProvider = provider) -``` - -Active `observe` flows receive updates on every `set` or `resetOverride` call. `clear()` removes all keys but does **not** emit change signals to observers — call `resetOverride` per param when reactive teardown is required. - -!!! note - `NSUserDefaults` returns a default value (0, `false`, `""`) when a key is absent. The provider checks `objectForKey` first to correctly distinguish "not set" from "set to the zero value". - ---- - -### JavaPreferencesConfigValueProvider - -JVM-only. Persists overrides using [`java.util.prefs.Preferences`](https://docs.oracle.com/en/java/docs/books/tutorial/essential/environment/prefs.html). Storage is OS-specific: registry on Windows, plist on macOS, `~/.java` on Linux. - -**Supported types:** `String`, `Int`, `Long`, `Float`, `Double`, `Boolean`. - -**Custom types** require a registered `TypeConverter`. - -**Dependency:** - -```kotlin -implementation("dev.androidbroadcast.featured:javaprefs-provider") -``` - -**Setup:** - -```kotlin -// Uses the default node "featured" under the user root -val provider = JavaPreferencesConfigValueProvider() - -// Or supply a custom Preferences node -val node = Preferences.userRoot().node("com/example/app/flags") -val provider = JavaPreferencesConfigValueProvider(node) - -val configValues = ConfigValues(localProvider = provider) -``` - -**Custom type (enum) example:** - -```kotlin -provider.registerConverter(enumConverter()) -``` - -All I/O is dispatched on `Dispatchers.IO`. Active `observe` flows receive updates on every `set` or `resetOverride` call. - ---- - -## Built-in remote providers - -### FirebaseConfigValueProvider - -Wraps [Firebase Remote Config](https://firebase.google.com/docs/remote-config). Remote values override local values when present. - -**Supported types natively:** `String`, `Boolean`, `Int`, `Long`, `Double`, `Float`. - -Enum types are resolved automatically by name — no explicit converter needed. For other custom types, register a `Converter` on the `converters` property: - -```kotlin -provider.converters.put(Converter { MyEnum.fromString(it.asString()) }) -``` - -**Dependency:** - -```kotlin -implementation("dev.androidbroadcast.featured:firebase-provider") -``` - -**Setup:** - -```kotlin -val configValues = ConfigValues( - localProvider = DataStoreConfigValueProvider(dataStore), - remoteProvider = FirebaseConfigValueProvider(), -) - -// Fetch and activate on app start — call from a coroutine -lifecycleScope.launch { configValues.fetch() } -``` - -Pass a custom `FirebaseRemoteConfig` instance if you manage the Firebase lifecycle yourself: - -```kotlin -FirebaseConfigValueProvider(remoteConfig = FirebaseRemoteConfig.getInstance()) -``` - -**Fetch strategy:** - -- `configValues.fetch()` calls `fetchAndActivate()` by default — values become immediately available after the call returns. -- Pass `activate = false` to fetch without activating immediately: - -```kotlin -configValues.fetch(activate = false) -// activate at the right moment later -configValues.fetch(activate = true) -``` - -- A `FetchException` is thrown on network errors, timeouts, or service unavailability. Wrap the call in a try/catch and implement exponential backoff for retries. - -**Firebase project setup:** - -1. Add `google-services.json` (Android) or `GoogleService-Info.plist` (iOS) to your project. -2. In the [Firebase console](https://console.firebase.google.com/), navigate to **Remote Config**. -3. Add parameters whose keys match your `ConfigParam.key` values. -4. Publish the configuration, then call `configValues.fetch()` at app start. - ---- - -## Custom types - -All providers that serialize values as strings (`DataStoreConfigValueProvider`, `SharedPreferencesProviderConfig`, `JavaPreferencesConfigValueProvider`) support custom types via `TypeConverter`. - -The library ships `enumConverter()` for any enum class: - -```kotlin -enum class Theme { LIGHT, DARK, SYSTEM } - -provider.registerConverter(enumConverter()) -``` - -For non-enum types, implement `TypeConverter` directly: - -```kotlin -val uuidConverter = TypeConverter( - fromString = { UUID.fromString(it) }, - toString = UUID::toString, -) -provider.registerConverter(UUID::class, uuidConverter) -``` - -Register converters **before** the first `get`, `set`, or `observe` call for the corresponding type. - ---- - -## Writing a custom provider - -### Custom local provider - -Implement `LocalConfigValueProvider`: - -```kotlin -class MyLocalProvider : LocalConfigValueProvider { - override suspend fun get(param: ConfigParam): ConfigValue? { … } - override fun observe(param: ConfigParam): Flow> { … } - override suspend fun set(param: ConfigParam, value: T) { … } - override suspend fun resetOverride(param: ConfigParam) { … } - override suspend fun clear() { … } -} -``` - -### Custom remote provider - -Implement `RemoteConfigValueProvider`: - -```kotlin -class MyRemoteProvider : RemoteConfigValueProvider { - override suspend fun fetch(activate: Boolean) { /* fetch from your backend */ } - override suspend fun get(param: ConfigParam): ConfigValue? { … } - override fun observe(param: ConfigParam): Flow> { … } -} -``` - ---- - -## Provider composition - -`ConfigValues` accepts one local provider and one remote provider: - -```kotlin -val configValues = ConfigValues( - localProvider = DataStoreConfigValueProvider(dataStore), - remoteProvider = FirebaseConfigValueProvider(), -) -``` - -Either provider is optional, but at least one must be supplied. - -## Provider resolution order - -When `ConfigValues.getValue(param)` is called: - -1. Check remote provider — return value if present. -2. Check local provider — return value if present. -3. Return `ConfigValue(param, param.defaultValue, Source.DEFAULT)`. - -Overrides written via `configValues.override(param, value)` are written to the **local** provider and survive remote fetches. - -## Value source - -Every `ConfigValue` carries a `source` field indicating where the value came from: - -| Source | Meaning | -|---|---| -| `REMOTE` | Fetched from the remote provider | -| `REMOTE_DEFAULT` | Remote provider returned its own default (e.g. Firebase in-app default) | -| `LOCAL` | Written by a local provider override | -| `DEFAULT` | Fell back to `ConfigParam.defaultValue` | -| `UNKNOWN` | Source could not be determined | - -Use `source` for debugging or analytics to understand which layer is serving each value. diff --git a/docs/guides/r8-verification.md b/docs/guides/r8-verification.md deleted file mode 100644 index 51cffe8..0000000 --- a/docs/guides/r8-verification.md +++ /dev/null @@ -1,78 +0,0 @@ -# R8 Dead-Code Elimination Verification - -## Why it matters - -Featured's core guarantee for local flags is that when a flag value is fixed at build time, -the code reachable only through the disabled branch is completely removed from the final APK. -This relies on R8 honouring the `-assumevalues` ProGuard rules generated by -`ProguardRulesGenerator`. - -A rule that is syntactically correct but semantically wrong would silently fail to eliminate -dead code. The `featured-shrinker-tests` module gives automated, deterministic verification -that the exact rule format produced by the plugin is sufficient for R8 to perform DCE. - -## How it works - -The tests use a three-step synthetic pipeline: - -1. **Bytecode generation (ASM)** — `SyntheticBytecodeFactory` builds `.class` files in - memory that mirror the structure the plugin generates at build time: a `ConfigValues` - holder, an extensions class that reads from it, branch-target classes (`IfBranchCode`, - `ElseBranchCode`, `PositiveCountCode`), and a caller entry point. - -2. **Rules generation** — `ProguardRulesWriter` writes `.pro` files in the exact format - `ProguardRulesGenerator` produces, optionally including the `-assumevalues` block. - -3. **R8 invocation** — `R8TestHarness.runR8()` calls R8 programmatically via - `R8Command.builder()`, producing an output JAR with DCE applied. - -After each run, `JarAssertions` inspects the output JAR and asserts which classes are -present or absent, proving that the rule caused (or did not cause) elimination. - -## Test scenarios - -### Boolean flags (`R8BooleanFlagEliminationTest`) - -| Test | Rule | Expected | -|------|------|----------| -| `if-branch class is eliminated when boolean flag returns false` | `-assumevalues … return false` | `IfBranchCode` absent; `ElseBranchCode` present | -| `else-branch class is eliminated when boolean flag returns true` | `-assumevalues … return true` | `ElseBranchCode` absent; `IfBranchCode` present | -| `both branch classes survive when no boolean assumevalues rule is present` | No `-assumevalues` | Both classes present | - -The third test is a control: it proves that elimination is caused by the rule, not by R8's -own constant-folding. - -### Int flags (`R8IntFlagEliminationTest`) - -| Test | Rule | Expected | -|------|------|----------| -| `guarded class is eliminated when int flag is assumed to return zero` | `-assumevalues … return 0` | `PositiveCountCode` absent; `IntCaller` present | -| `guarded class survives when int flag has no assumevalues rule` | No `-assumevalues` | Both classes present | - -With `-assumevalues return 0`, R8 constant-folds `0 > 0` to `false` and eliminates the -guarded block entirely. - -## Running the tests - -```bash -./gradlew :featured-shrinker-tests:test -``` - -To run only one test class: - -```bash -./gradlew :featured-shrinker-tests:test --tests "dev.androidbroadcast.featured.shrinker.r8.R8BooleanFlagEliminationTest" -``` - -## Adding new scenarios - -1. **New flag type** — add bytecode generators in `SyntheticBytecodeFactory.kt`, JAR - assembler functions in `JarAssembler.kt`, rule writers in `ProguardRulesWriter.kt`, and - a new test class in `r8/` that extends `R8TestHarness`. - -2. **New rule variant** — add a `write*Rules()` function in `ProguardRulesWriter.kt` and a - corresponding `@Test` method in the relevant test class. - -3. **Verifying a rule format change** — update the `write*Rules()` function to match the - new format produced by `ProguardRulesGenerator`, then run the tests to confirm DCE still - works. diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 57ad4bc..0000000 --- a/docs/index.md +++ /dev/null @@ -1,32 +0,0 @@ -# Featured - -[![CI](https://github.com/AndroidBroadcast/Featured/actions/workflows/ci.yml/badge.svg)](https://github.com/AndroidBroadcast/Featured/actions/workflows/ci.yml) -[![Maven Central](https://img.shields.io/maven-central/v/dev.androidbroadcast.featured/core.svg?label=Maven%20Central)](https://central.sonatype.com/search?q=dev.androidbroadcast.featured) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/AndroidBroadcast/Featured/blob/main/LICENSE) - -**Featured** is a type-safe, reactive feature-flag and configuration management library for Kotlin Multiplatform (Android, iOS, JVM). Declare flags in shared Kotlin code, read them at runtime from local or remote providers, and let the Gradle plugin dead-code-eliminate disabled flags from your production binaries. - -## Use cases - -- Ship code guarded by a flag that is off by default; enable it via Firebase Remote Config when you are ready to roll out. -- Override individual flags during development or QA without touching a remote backend. -- Eliminate dead code from Release binaries: the Gradle plugin generates R8 rules (Android/JVM) and an xcconfig file (iOS) that let the respective compilers strip disabled flag code paths at build time. - -## Key types - -| Type | Role | -|------|------| -| `ConfigParam` | Declares a named, typed configuration key with a default value | -| `ConfigValue` | Wraps a param's current value and its source (DEFAULT / LOCAL / REMOTE) | -| `ConfigValues` | Container that composes local and remote providers | -| `LocalConfigValueProvider` | Interface for writable, observable local storage | -| `RemoteConfigValueProvider` | Interface for fetch-based remote configuration | - -## Quick links - -- [Getting Started](getting-started.md) — installation and first flag in 5 minutes -- [Android guide](guides/android.md) — DataStore, Compose, debug UI -- [iOS guide](guides/ios.md) — SKIE interop, Swift DCE via xcconfig -- [JVM guide](guides/jvm.md) — standalone JVM usage -- [Providers](guides/providers.md) — all built-in providers explained -- [API Reference](api/index.md) — full KDoc-generated reference diff --git a/docs/ios-integration.md b/docs/ios-integration.md deleted file mode 100644 index cd3d83e..0000000 --- a/docs/ios-integration.md +++ /dev/null @@ -1,147 +0,0 @@ -# iOS Integration Guide: Swift Dead Code Elimination with #if - -This guide explains how to use the `featured-gradle-plugin` xcconfig output to eliminate -disabled feature-flag code paths from your iOS Release binaries at compile time. - -## How it works - -For every local boolean flag declared in `featured { localFlags { } }` with `default = false` -in your shared Kotlin module, the plugin generates a `DISABLE_` Swift compilation -condition. Xcode reads these conditions from an xcconfig file and passes them to the Swift -compiler, which removes any `#if !DISABLE_*` guarded block from the binary entirely — -with no runtime overhead. - -### Key transformation - -| Kotlin flag key | Generated condition | -|----------------------|--------------------------| -| `new_checkout` | `DISABLE_NEW_CHECKOUT` | -| `experimentalUi` | `DISABLE_EXPERIMENTAL_UI`| -| `my_feature_flag` | `DISABLE_MY_FEATURE_FLAG`| - -Only local boolean flags with `default = false` produce a condition. Flags with -`default = true`, non-boolean flags, and flags declared in `remoteFlags { }` are excluded. - -## Step 1: Generate the xcconfig file - -Run the Gradle task from the module that contains your `featured { localFlags { } }` DSL declarations -(usually your `shared` or `core` module): - -```bash -./gradlew :shared:generateXcconfig -``` - -The file is written to: - -``` -shared/build/featured/FeatureFlags.generated.xcconfig -``` - -Example output: - -```xcconfig -# Auto-generated by featured-gradle-plugin — do not edit -# Include this file in your Xcode Release configuration -SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) DISABLE_NEW_CHECKOUT DISABLE_EXPERIMENTAL_UI -``` - -## Step2: Make the file available to Xcode - -The generated file lives inside the Gradle build directory and is not committed to source -control. Copy or symlink it to a stable path inside your Xcode project tree. - -**Convention:** `iosApp/Configuration/FeatureFlags.generated.xcconfig` - -```bash -# Copy approach (run after every generateXcconfig invocation) -cp shared/build/featured/FeatureFlags.generated.xcconfig \ - iosApp/Configuration/FeatureFlags.generated.xcconfig - -# Symlink approach (resolved automatically on every build) -ln -sf ../../shared/build/featured/FeatureFlags.generated.xcconfig \ - iosApp/Configuration/FeatureFlags.generated.xcconfig -``` - -Add the generated file to `.gitignore` when using the copy approach: - -```gitignore -iosApp/Configuration/FeatureFlags.generated.xcconfig -``` - -The repository ships a placeholder file at this path so the Xcode project reference -remains valid in a clean checkout. - -## Step3: Configure Xcode (one-time) - -1. Open `iosApp/iosApp.xcodeproj` in Xcode. -2. Select the project in the Project Navigator → **Info** tab → **Configurations**. -3. Expand the **Release** configuration. -4. For your app target, set the configuration file to **FeatureFlags.generated.xcconfig**. - -Only configure the Release configuration. Debug builds intentionally omit the xcconfig -so all features remain reachable during development. - -## Step4: Guard Swift entry points with #if - -Wrap every Swift entry point for a feature behind the corresponding `#if !DISABLE_*` -condition. The Swift compiler removes the entire guarded block from Release binaries. - -### View entry point - -```swift -// Entry point guarded by local boolean flag new_checkout (default = false) -#if !DISABLE_NEW_CHECKOUT -NewCheckoutButton() -#endif -``` - -### Deeplink handler - -```swift -#if !DISABLE_NEW_CHECKOUT -case .newCheckout: NewCheckoutCoordinator.start() -#endif -``` - -### Tab bar item - -```swift -#if !DISABLE_NEW_CHECKOUT -TabItem(title: "Checkout", systemImage: "cart") { - NewCheckoutView() -} -#endif -``` - -### AppDelegate / SceneDelegate - -```swift -#if !DISABLE_NEW_CHECKOUT -setupNewCheckoutObservers() -#endif -``` - ---- - -## Automation: regenerate on every build - -To keep the xcconfig in sync without a manual step, add a pre-build Run Script phase -to your Xcode target (before the Compile Sources phase): - -```bash -cd "${SRCROOT}/.." -./gradlew :shared:generateXcconfig --quiet -cp shared/build/featured/FeatureFlags.generated.xcconfig \ - iosApp/Configuration/FeatureFlags.generated.xcconfig -``` - -Set **Based on dependency analysis** to **off** so it runs on every build. - -## Reference - -- `FeatureFlags.swift` — Swift wrapper with usage examples and setup guidance -- `iosApp/Configuration/FeatureFlags.generated.xcconfig` — placeholder / copy destination -- `generateIosConstVal` — Gradle task that generates `expect`/`actual const val` for local flags -- `resolveFeatureFlags` — Gradle task that resolves DSL-declared flags before code generation -- `GenerateXcconfigTask.kt` — Gradle task that writes the xcconfig -- `XcconfigGenerator.kt` — key transformation and file generation logic diff --git a/docs/known-limitations.md b/docs/known-limitations.md deleted file mode 100644 index 415b5f0..0000000 --- a/docs/known-limitations.md +++ /dev/null @@ -1,49 +0,0 @@ -# Known Limitations - -This document tracks behaviour gaps and deferred work that consumers of -`featured-gradle-plugin` and related modules should be aware of. Each entry -links to a tracking issue and the milestone in which it is expected to be -resolved. - -## Configuration Cache - -`featured-gradle-plugin` officially supports the Gradle Configuration Cache -on Gradle 9.x and AGP 9.x. Verification artefacts: - -- `docs/cc-verification/fixture-report-2026-05-17.md` — fixture project audit -- `docs/cc-verification/sample-report-2026-05-17.md` — sample modules audit -- `docs/cc-verification/agp-propagation-check-2026-05-16.md` — AGP provider - propagation audit (see `AndroidProguardWiring` fallback) - -Known upstream gaps observed during verification, if any, are listed in the -sample report under "Per-violation table". - -## Isolated projects - -`featured-gradle-plugin` is **Configuration-Cache safe** but **not -isolated-projects safe**. - -Source: `FeaturedPlugin.kt:157` — `wireToRootAggregator()` calls -`target.rootProject` to lazily register the `scanAllLocalFlags` aggregator on -the root project. Cross-project mutation from a non-root project violates the -isolated-projects contract. - -The behaviour is intentional for `1.0.0-Beta`: it lets consumers `apply` the -plugin in any subproject without manual root wiring, which is the dominant -usage pattern today. - -**Migration path (v1.1.0):** convert the aggregator wiring into a settings -plugin, or change the contract so consumers register the aggregator once in -the root `build.gradle.kts` and subproject plugins only `dependsOn` it. - -Tracking issue: -[androidbroadcast/Featured#186](https://github.com/androidbroadcast/Featured/issues/186) -(milestone `v1.1.0`). - -## Third-party plugin CC gaps - -Third-party Gradle plugins occasionally introduce Configuration Cache -violations through transitive plugin application. We track such gaps in the -sample audit (`docs/cc-verification/sample-report-2026-05-17.md`) when they -surface. None of these are caused by `featured-gradle-plugin` itself; the -plugin's own task graph is CC-clean per the fixture audit. diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 2975870..91b20a2 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -19,7 +19,7 @@ struct ContentView: View { // #if entry point pattern demo: DISABLE_NEW_CHECKOUT is set in // FeatureFlags.generated.xcconfig when @LocalFlag new_checkout has // defaultValue = false. The compiler removes this block in Release. - // See FeatureFlags.swift and docs/ios-integration.md for setup. + // See FeatureFlags.swift and https://github.com/AndroidBroadcast/Featured/wiki/iOS-DCE-xcconfig for setup. #if !DISABLE_NEW_CHECKOUT NewCheckoutBanner() #endif diff --git a/iosApp/iosApp/FeatureFlags.swift b/iosApp/iosApp/FeatureFlags.swift index 2664c3a..0b84f8a 100644 --- a/iosApp/iosApp/FeatureFlags.swift +++ b/iosApp/iosApp/FeatureFlags.swift @@ -29,7 +29,7 @@ import FeaturedSampleApp // // When the flag is defaultValue = false the compiler strips the guarded code // from the Release binary entirely, with zero runtime overhead. -// See docs/ios-integration.md for the full integration guide. +// See https://github.com/AndroidBroadcast/Featured/wiki/iOS-DCE-xcconfig for the full integration guide. /// A type-safe wrapper around a KMP CoreConfigParam. /// diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index 5eaba4d..0000000 --- a/mkdocs.yml +++ /dev/null @@ -1,66 +0,0 @@ -site_name: Featured -site_description: Type-safe, reactive feature-flag and configuration management for Kotlin Multiplatform -site_url: https://androidbroadcast.github.io/Featured/ -repo_name: AndroidBroadcast/Featured -repo_url: https://github.com/AndroidBroadcast/Featured -edit_uri: edit/main/docs/ - -theme: - name: material - palette: - - media: "(prefers-color-scheme: light)" - scheme: default - primary: deep purple - accent: purple - toggle: - icon: material/brightness-7 - name: Switch to dark mode - - media: "(prefers-color-scheme: dark)" - scheme: slate - primary: deep purple - accent: purple - toggle: - icon: material/brightness-4 - name: Switch to light mode - features: - - navigation.tabs - - navigation.sections - - navigation.top - - search.suggest - - search.highlight - - content.code.copy - - content.tabs.link - -exclude_docs: | - superpowers/ - -plugins: - - search - -markdown_extensions: - - admonition - - pymdownx.details - - pymdownx.superfences - - pymdownx.highlight: - anchor_linenums: true - - pymdownx.inlinehilite - - pymdownx.snippets - - pymdownx.tabbed: - alternate_style: true - - tables - - toc: - permalink: true - -nav: - - Home: index.md - - Getting Started: getting-started.md - - Guides: - - Android: guides/android.md - - iOS: guides/ios.md - - JVM: guides/jvm.md - - Providers: guides/providers.md - - Best Practices: guides/best-practices.md - - R8 DCE Verification: guides/r8-verification.md - - iOS Dead-Code Elimination: ios-integration.md - - API Reference: api/index.md - - Changelog: changelog.md