diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f35f532..97d2b8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ main ] + branches: [ main, develop ] pull_request: - branches: [ main ] + branches: [ main, develop ] permissions: contents: read @@ -28,7 +28,6 @@ jobs: :providers:sharedpreferences:koverVerify :providers:firebase:koverVerify :featured-compose:koverVerify - :featured-registry:koverVerify :featured-testing:koverVerify - uses: actions/upload-artifact@v7 if: always() diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9ae0ba7..2f0b7ee 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,9 +2,17 @@ name: CodeQL on: push: - branches: [ main ] + branches: [ main, develop ] + paths-ignore: + - '.github/workflows/**' + - 'docs/**' + - '**/*.md' pull_request: - branches: [ main ] + branches: [ main, develop ] + paths-ignore: + - '.github/workflows/**' + - 'docs/**' + - '**/*.md' schedule: - cron: "0 0 * * 0" # Every Sunday at midnight @@ -31,9 +39,13 @@ jobs: - uses: github/codeql-action/init@v4 with: languages: java-kotlin + build-mode: manual - name: Build for CodeQL - # autobuild looks for 'testClasses' which doesn't exist in KMP; build manually instead - run: ./gradlew assembleDebug + # autobuild looks for 'testClasses' which doesn't exist in KMP; build manually instead. + # --no-build-cache + --rerun-tasks force Kotlin to actually recompile so the CodeQL + # tracer observes source. Without this, cached/up-to-date compile tasks are skipped and + # CodeQL fails with "no source code seen during build" (exit code 32). + run: ./gradlew assembleDebug --no-build-cache --rerun-tasks - uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 4388ac5..7646f06 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -2,7 +2,7 @@ name: Dependency Review on: pull_request: - branches: [ main ] + branches: [ main, develop ] permissions: contents: read diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index a5624cd..0000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,77 +0,0 @@ -name: Publish Docs - -on: - push: - branches: - - main - pull_request: - branches: - - main - tags: - - "v[0-9]+.[0-9]+.[0-9]+" - - "v[0-9]+.[0-9]+.[0-9]+-*" - -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 - if: github.event_name == 'push' - - 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/.github/workflows/publish.yml b/.github/workflows/publish.yml index f95a3bc..ee87f8f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,6 +7,7 @@ on: - "v[0-9]+.[0-9]+.[0-9]+-*" branches: - main + - develop permissions: contents: read 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/CHANGELOG.md b/CHANGELOG.md index f8aea62..fbbfb2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,47 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.0.0] - 2026-05-30 + +### Removed + +- `featured-registry` module — the runtime `FlagRegistry` global singleton and its `FlagRegistryDelegate` expect/actual are removed. Use `GeneratedFeaturedRegistry.all` (produced by the `dev.androidbroadcast.featured.application` plugin) or build an explicit `List>` instead. +- `featured-gradle-plugin` — `generateFlagRegistrar` task, `FlagRegistrarGenerator`, and `GenerateFlagRegistrarTask` are removed. Per-module `GeneratedFlagRegistrar.kt` files are no longer generated. +- Sample API — `registerSampleFlags()` is removed (was specific to the sample app's legacy wiring). The sample now uses `GeneratedFeaturedRegistry.all` (produced by the aggregator plugin) instead. + +### Changed + +- `FeatureFlagsDebugScreen` signature is now `(configValues: ConfigValues, registry: List>, modifier: Modifier = Modifier)` — accepts an explicit registry list instead of reading the (removed) `FlagRegistry` singleton. Pass `GeneratedFeaturedRegistry.all` for the recommended aggregator-plugin flow, or build the list inline for small projects. +- `:sample:shared` is now a pure aggregator: it applies `dev.androidbroadcast.featured.application`, declares `featuredAggregation(project(":sample:feature-*"))`, and consumes `GeneratedFeaturedRegistry.all`. The hand-written `SampleFeatureFlags.kt` is removed. +- Generator file names include a module-derived suffix (`GeneratedLocalFlagsSampleFeatureCheckout.kt`, etc.) — eliminates JVM class-name collisions when multiple modules share the same classpath. `@file:JvmName` is no longer emitted. +- `ExtensionFunctionGenerator` emits non-suspend `is*Enabled()` / `get*()` extension functions — they delegate to `getValueCached` and can be called from any context without a coroutine. Callers that previously wrapped them in `runBlocking { … }` or a coroutine scope can drop the wrapper. +- `ConfigValues.resetOverride` re-resolves the effective value synchronously through the full provider priority chain; [getValueCached] reflects the updated value immediately after the call returns. +- Generated `GeneratedLocalFlagsX` / `GeneratedRemoteFlagsX` objects are now `internal` to their declaring Gradle module — each feature module's flag declarations are an implementation detail and no longer leak across module boundaries. Cross-module flag introspection (e.g. the debug screen) flows exclusively through `GeneratedFeaturedRegistry.all`, which the aggregator plugin builds from per-module manifests. The sample app demonstrates the per-module wiring pattern: one `ConfigValues` per feature module plus a dedicated debug aggregator, all sharing the same `LocalConfigValueProvider`. +- The plugin's ProGuard-rules generation task is renamed from `generateProguardRules` to `generateFeaturedProguardRules` to avoid name collisions with other plugins. (#190) +- User documentation moved from the in-repo MkDocs site to the [GitHub Wiki](https://github.com/AndroidBroadcast/Featured/wiki); the `docs/` site and `mkdocs.yml` are removed from the repository. (#193) + +### Added + +- `ConfigValues.getValueCached(param: ConfigParam): ConfigValue` — non-suspend synchronous reader. Returns the last-written `ConfigValue` from the in-memory cache; the cache is warmed on the first `getValue` / `override` / `fetch` call, and returns `Source.DEFAULT` until then. +- `ConfigValues.isEnabled(param: ConfigParam): Boolean` — non-suspend extension (replaces the former `suspend` variant). Delegates to `getValueCached`; safe to call from Composable functions, `init` blocks, and non-coroutine contexts. + +- Featured library plugin now publishes a per-module feature-flag manifest as a consumable Gradle artifact (`featuredManifest` configuration, schema v1). Existing flag-generation pipeline is unchanged. Consumer-side aggregation arrives in a follow-up release. +- New `dev.androidbroadcast.featured.application` Gradle plugin: aggregates `featured-manifest.json` artifacts from project dependencies declared via `featuredAggregation(project(...))` and generates `object GeneratedFeaturedRegistry { val all: List> }` in `build/generated/featured/commonMain/`. Apply alongside `dev.androidbroadcast.featured` in the application module; wire the output directory into your source set manually (e.g., `kotlin.sourceSets.commonMain.kotlin.srcDir(...)`). Modules declaring `enum` flags also require a regular `implementation(project(...))` dependency in the consumer so the enum class is on the compile classpath; primitive-only modules need only `featuredAggregation(...)`. +- Three KMP sample feature modules — `:sample:feature-checkout`, `:sample:feature-promotions`, `:sample:feature-ui` — each declaring its own flags via the `featured { ... }` DSL. Serves as the canonical multi-module reference. +- `EnumDropdown` component in `featured-debug-ui` for overriding `enum`-typed flags in `FeatureFlagsDebugScreen`; `ConfigParam` now carries `enumConstants: List?` populated by codegen so the debug UI can render the dropdown without reflection. +- `featured-gradle-plugin` lives at the repo root as an included build; `pluginManagement { includeBuild("featured-gradle-plugin") }` in the root `settings.gradle.kts` exposes it to all main-build subprojects without a version coordinate. + +### Fixed + +- `ConfigValues.observe()` now wraps provider `Flow` collection in `catch` — exceptions thrown by a local or remote provider are routed to `onProviderError` instead of propagating and breaking the observation flow. (#196) +- Restored R8 per-function DCE: ProGuard `-assumevalues` rules now target the actual Kotlin-compiled class name (`GeneratedFlagExtensionsXKt`). The rules were silently no-op since `@file:JvmName` was removed in an earlier PR; unused boolean flags are once again eliminated at shrinking time. +- iOS framework can now `export(project(":sample:feature-*"))` without the K/N `ObjCExportCodeGenerator` crashing — requires `api(project(...))` linkage in the aggregator module so K/N has access to type adapters for generic `ConfigParam` specializations. + +### Platform stability + +- **Android — Stable.** Public API and behavior are covered by SemVer. +- **iOS (SKIE / Swift DCE) — Preview.** Functional, but the Swift-facing API and the SPM packaging may change in minor releases without a major bump. +- **JVM — Preview.** Functional, but the API may change in minor releases without a major bump. ## [1.0.0-Beta1] - 2026-05-17 @@ -84,5 +124,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - License mismatch: use MIT in all POM declarations (#174) - Stale artifact IDs in quick-start docs (#179) -[Unreleased]: https://github.com/androidbroadcast/Featured/compare/v1.0.0-Beta1...HEAD +[Unreleased]: https://github.com/androidbroadcast/Featured/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/androidbroadcast/Featured/compare/v1.0.0-Beta1...v1.0.0 [1.0.0-Beta1]: https://github.com/androidbroadcast/Featured/releases/tag/v1.0.0-Beta1 diff --git a/CLAUDE.md b/CLAUDE.md index 49ab66f..e787fba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,19 +4,119 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**Featured** is a Kotlin Multiplatform (KMP) configuration management library supporting Android, iOS (via SKIE), and JVM. It provides a type-safe, reactive configuration system with swappable local and remote providers. +**Featured** is a Kotlin Multiplatform feature-flag / configuration management library (Android, iOS via SKIE, JVM / Compose Desktop). Two things make it more than a typed wrapper around shared-prefs: + +1. **A Gradle plugin family** generates typed `ConfigParam` objects + ergonomic `ConfigValues` extensions from a `featured { localFlags { … } }` DSL — no hand-written keys. +2. **Release-time dead-code elimination.** Flags with `default = false` emit per-function R8 `-assumevalues` rules (Android/JVM) and an xcconfig with `DISABLE_` Swift conditions (iOS). Disabled branches are physically stripped from release binaries. + +`develop` is the integration branch; PRs target `develop`, not `main`. ## Core Concepts -- **`ConfigParam`** — declares a named, typed configuration key with a default value -- **`ConfigValue`** — wraps a param + its current value; supports reactive observation via `Flow` -- **`ConfigValues`** — container holding all `ConfigValue` instances; accepts optional local and remote providers -- **`LocalConfigValueProvider` / `RemoteConfigValueProvider`** — interfaces implemented by each provider module +- **`ConfigParam`** — declared name + typed default. The Gradle plugin emits these as `object GeneratedLocalFlagsX` / `GeneratedRemoteFlagsX` per-module (since PR #202: **`internal`**, not public). +- **`ConfigValue`** — observable value (`Flow`) for a single `ConfigParam`. +- **`ConfigValues`** — container; constructed with optional `LocalConfigValueProvider` and `RemoteConfigValueProvider`. **Remote overrides local.** Apps normally construct **one `ConfigValues` per feature module**, all sharing the same provider. +- **Aggregator plugin (`dev.androidbroadcast.featured.application`)** — consumes `featured-manifest.json` from every `featuredAggregation(project(...))` dependency and generates `GeneratedFeaturedRegistry.all: List>`. This is the *only* cross-module flag listing surface; the per-module generated objects stay `internal`. +- **Observe-bridge convention** — each feature module ships public `ConfigValues` extensions (`fooFlow()`, `setFoo()`) so the UI never references `GeneratedLocalFlagsX` directly. + +## Module Map + +``` +core ───────────────── public abstractions (ConfigParam/Value/Values, provider interfaces) +featured-compose ──── Compose-Multiplatform extension (collectAsState helpers) +featured-debug-ui ─── FeatureFlagsDebugScreen (UI-agnostic, reads GeneratedFeaturedRegistry.all) +featured-testing ──── test doubles (InMemoryConfigValueProvider, etc.) +featured-platform ─── platform metadata module +featured-bom ──────── Maven BOM +featured-detekt-rules / featured-lint-rules ── static checks for flag misuse +featured-shrinker-tests ── R8 DCE integration tests +providers/{configcat,datastore,firebase,javaprefs,nsuserdefaults,sharedpreferences} +featured-gradle-plugin/ ── published Gradle plugin (included build, dogfooded on :sample:feature-*) +sample/{shared,feature-checkout,feature-promotions,feature-ui,android-app,desktop} +iosApp/ Swift consumer of FeaturedSampleApp.framework +``` + +The plugin is structured as an **included build** (`includeBuild("featured-gradle-plugin")` in the root `settings.gradle.kts`), not a regular subproject. This breaks the chicken-and-egg cycle of applying the plugin to `:sample:feature-*` modules within the same repo for dogfooding. + +## Build / Test Commands + +```bash +./gradlew assemble # build everything +./gradlew test # all JVM unit tests +./gradlew :core:test # one module +./gradlew :core:test --tests "dev.androidbroadcast.featured.ConfigValuesTest" +./gradlew :core:koverVerify # core requires >=90% line coverage +./gradlew :core:koverHtmlReport +./gradlew :core:connectedAndroidTest # needs device/emulator +./gradlew :featured-debug-ui:allTests # KMP module — JVM + Android + iOS targets +./gradlew :featured-gradle-plugin:test # plugin unit tests (43+ cases) +./gradlew spotlessCheck # required before push +./gradlew spotlessApply # auto-fix +./gradlew publishToMavenLocal # publish the Gradle plugin locally +``` + +Sample build / install: + +```bash +./gradlew :sample:android-app:installDebug # Android sample +./gradlew :sample:desktop:run # Compose Desktop sample +``` + +**Plugin codegen tasks (per-module, when the project applies `dev.androidbroadcast.featured`):** + +- `generateConfigParam` — typed `ConfigParam` objects + `ConfigValues` extensions +- `generateFeaturedProguardRules` — R8 `-assumevalues` rules for local flags +- `generateIosConstVal` / `generateXcconfig` — Swift DCE inputs +- `generateFeaturedManifest` — emits `featured-manifest.json` consumed by the aggregator +- `generateFeaturedRegistry` (aggregator-only) — produces `GeneratedFeaturedRegistry.kt` + +## Plugin Architecture (highest-leverage to understand) + +Two plugins, two roles: + +| Plugin ID | Where | Role | +|---|---|---| +| `dev.androidbroadcast.featured` | every feature / library module that declares flags | Exposes the `featured { }` DSL; generates per-module `ConfigParam` objects, observe extensions, ProGuard rules, iOS const-val + xcconfig, and a `featured-manifest.json` artifact (consumable Gradle variant `featured-manifest`). | +| `dev.androidbroadcast.featured.application` | the app / aggregator module only | Adds a `featuredAggregation` `dependencyScope` configuration. Resolves the `featured-manifest` variant from each declared project dep, merges them, and generates `GeneratedFeaturedRegistry.all`. **Min Gradle 8.5+** (uses `dependencyScope` / `resolvable` API). | + +**Enum-flag classpath gotcha.** `featuredAggregation(project(":foo"))` only pulls the manifest variant — not `:foo`'s compile classpath. If `:foo` declares an `enum` flag whose enum type lives in `:foo`, the aggregator module must also declare `implementation(project(":foo"))` so the enum class is visible at compile time. Primitive-only modules need no extra dependency. + +**Auto-wiring policy.** The aggregator does **not** auto-wire its output into a source set — the consumer module wires it manually because the plugin can't safely assume KMP vs. AGP vs. plain JVM: + +```kotlin +kotlin.sourceSets.getByName("commonMain").kotlin.srcDir( + tasks.named("generateFeaturedRegistry").map { it.outputs.files.singleFile.parentFile } +) +``` + +## Multi-Module Pattern (canonical, demonstrated in `:sample`) + +Real apps with N feature modules wire **N production `ConfigValues`** (one per feature), all sharing one `LocalConfigValueProvider`. The shell additionally builds one extra `ConfigValues` for `FeatureFlagsDebugScreen` (Android only in the sample; Desktop/iOS omit the debug surface). Each feature module: + +1. Declares its flags in its own `build.gradle.kts: featured { localFlags { … } }`. +2. Exposes public `*FlagObservers.kt` extensions on `ConfigValues` (the only sanctioned cross-module API surface). +3. Owns its own `*FlagsViewModel` taking only its own `ConfigValues`. + +`GeneratedLocalFlagsX` / `GeneratedRemoteFlagsX` are `internal` to their module — never reference them across module boundaries. Use `GeneratedFeaturedRegistry.all` for cross-module flag listing. + +For non-reactive reads (logging, eager-conditional paths) use `configValues.getValueCached(param)` — the generated `isFooEnabled()` / `getFoo()` extensions are non-suspend and delegate to it (PR #201 restored this synchronous path; R8 DCE depends on it). + +## Project Conventions -**Provider priority:** remote values override local values when both are present. +- **Explicit API mode** is on for every KMP module — all public declarations need explicit visibility. Generated flag objects are deliberately `internal`. +- **Version catalog** (`gradle/libs.versions.toml`) is the single source of truth for dependency versions. +- **Spotless / ktlint** runs over `**/*.kt` and `**/*.kts` excluding `build/`. CI fails on `spotlessCheck`. +- **Public-API stability is reviewed manually in PRs** — there is no automated Binary Compatibility Validator gate (BCV was removed in #150). Reviewers check public-surface changes by hand. Featured has **no migration window** for breaking changes; breaking changes go in directly, the version number reflects it. +- **Branching:** `develop` is the integration branch; PRs go to `develop`, not `main`. `main` is updated only on releases. One logical change per PR — do not bundle. +- **Comment language:** English (per `.github/copilot-instructions.md`). +- **iOS:** SKIE is applied in `:core`; the XCFramework is named `FeaturedCore`. SKIE config is `skie.toml` at repo root. +- **R8:** the project relies on `android.enableR8.fullMode=true` and `android.r8.strictInputValidation=true`. The generated ProGuard rules + `-assumevalues` are what make DCE work. -## Key Conventions +## Where to Look First When… -- **Explicit API mode** on all KMP modules — every public declaration requires an explicit visibility modifier -- **Version catalog** (`gradle/libs.versions.toml`) is the single source of truth for dependency versions -- **Formatting:** run `./gradlew spotlessCheck` before pushing; `./gradlew spotlessApply` to auto-fix +- "Find how the DSL is parsed" → `featured-gradle-plugin/src/main/kotlin/.../FeaturedExtension.kt`, `FlagSpec.kt`, `FlagContainer.kt`. +- "Find codegen output shape" → `ConfigParamGenerator.kt`, `ExtensionFunctionGenerator.kt`, `ProguardRulesGenerator.kt`, `XcconfigGenerator.kt`, `IosConstValGenerator.kt` (all in `featured-gradle-plugin/src/main/kotlin/`). +- "Find aggregator wiring" → `FeaturedApplicationPlugin.kt` + `aggregation/` subpackage. +- "Find manifest format" → `manifest/` subpackage (`GenerateFeaturedManifestTask.kt`, `SCHEMA_VERSION`). +- "Verify R8 DCE behaviour" → `featured-shrinker-tests/` (integration tests over real `assembleRelease`). +- Sample wiring → `sample/android-app`, `sample/desktop`, `iosApp/`; per-feature flag observers → `sample/feature-*/.../*FlagObservers.kt`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08212cf..669b21a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,16 +63,14 @@ Featured follows [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH` - Changing a function signature in a way that requires call-site updates - Changing the behavior of an existing API in a way that requires migration -Binary Compatibility Validator (BCV) enforces this automatically — a CI check will fail if -a public API surface changes without an explicit `apiDump` update. +Public API changes are reviewed manually during code review — there is no automated Binary Compatibility Validator gate. Reviewers verify that any public-surface change is intentional and that the version bump reflects it. -## Deprecation Policy +## API Stability and Breaking Changes -1. An API is marked `@Deprecated` with a `ReplaceWith` suggestion and a `DeprecationLevel.WARNING`. -2. The deprecated API is kept for **at least one minor release** before being promoted to `ERROR` level. -3. APIs at `ERROR` level are removed in the **next major release**. +Featured has **no deprecation or migration window**. Breaking changes are made directly; the version number reflects the impact per the Versioning table above. -Example timeline: deprecated in `1.2.0` → error in `1.3.0` → removed in `2.0.0`. +- **Android (Stable):** a breaking public-API change (removed/renamed symbol, changed signature) requires a `MAJOR` version bump. +- **iOS (Preview) and JVM (Preview):** public API may change in `MINOR` releases without a major bump; no migration window is provided. ## Releasing a New Version diff --git a/README.md b/README.md index 9bf67df..229d120 100644 --- a/README.md +++ b/README.md @@ -4,586 +4,111 @@ [![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. ---- +## Platform stability -## Overview +| Platform | Status | +|---|---| +| Android | **Stable** | +| iOS (SKIE / DCE) | **Preview** | +| JVM | **Preview** | -**Use cases** +*Preview* means the platform is functional but its public API may change in minor releases without a major version bump. *Stable* platforms follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -- 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. - -```kotlin -// settings.gradle.kts -dependencyResolutionManagement { - repositories { - mavenCentral() - google() - } -} -``` +## Quick example ```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. - -### 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. +// Application.kt — wire up ConfigValues once +val dataStore = PreferenceDataStoreFactory.create { context.dataStoreFile("feature_flags.preferences_pb") } -```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()) -``` - -### 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) +// Read the generated extension anywhere +val isEnabled: Boolean = configValues.isNewCheckoutEnabled() ``` ---- - -## 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) -``` +## Multi-module pattern -### 2. Show the debug screen +In a multi-module app, construct one `ConfigValues` per feature module plus one debug aggregator, +all sharing the same `LocalConfigValueProvider`: ```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:generateProguardRules -``` - -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` +// Construct one ConfigValues per feature module + one debug aggregator, all over a shared provider +val sharedLocal: LocalConfigValueProvider = defaultLocalProvider(applicationContext) -```swift -// Entry point for the new checkout feature -#if !DISABLE_NEW_CHECKOUT -NewCheckoutButton() -#endif +val checkoutConfig = ConfigValues(localProvider = sharedLocal) +val promotionsConfig = ConfigValues(localProvider = sharedLocal) +val uiConfig = ConfigValues(localProvider = sharedLocal) -// Deep-link handler -#if !DISABLE_NEW_CHECKOUT -case .newCheckout: NewCheckoutCoordinator.start() -#endif +// Debug-only aggregator that the FeatureFlagsDebugScreen drives +val debugConfig = ConfigValues(localProvider = sharedLocal) -// 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 generateProguardRules - -# 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`, `generateProguardRules`, `generateConfigParam`, `generateFlagRegistrar`, `generateIosConstVal`, `generateXcconfig`) 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 +FeatureFlagsDebugScreen( + configValues = debugConfig, + registry = GeneratedFeaturedRegistry.all, +) ``` -### 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 +Each feature module owns its own `ConfigValues` and observes only its own flags (via public +observe-bridge extensions). The generated `GeneratedLocalFlagsX` / `GeneratedRemoteFlagsX` objects +are `internal` to their module — cross-module flag listing flows exclusively through +`GeneratedFeaturedRegistry.all`, which is built from the per-module manifests by the aggregator +plugin. The single source of truth for stored overrides is the shared `LocalConfigValueProvider`, +so writes from any instance propagate to every other one through its reactive `observe` flow. -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/build.gradle.kts b/build.gradle.kts index 0ace319..06558d4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,6 +14,14 @@ plugins { alias(libs.plugins.dokka) } +tasks.register("publishToMavenCentral") { + dependsOn(gradle.includedBuild("featured-gradle-plugin").task(":publishToMavenCentral")) +} + +tasks.register("publishToMavenLocal") { + dependsOn(gradle.includedBuild("featured-gradle-plugin").task(":publishToMavenLocal")) +} + spotless { val ktlintVersion = libs.versions.ktlint.get() kotlin { diff --git a/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigParam.kt b/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigParam.kt index 6a465af..0b65f54 100644 --- a/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigParam.kt +++ b/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigParam.kt @@ -58,6 +58,11 @@ public class ConfigParam * Useful for tracking parameter lifecycle. */ public val since: String? = null, + /** + * All declared constants of the enum type, or `null` for non-enum params. + * Populated by the code generator via `enumValues().toList()`; not set for hand-written params. + */ + public val enumConstants: List? = null, ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -70,7 +75,8 @@ public class ConfigParam valueType == other.valueType && description == other.description && category == other.category && - since == other.since + since == other.since && + enumConstants == other.enumConstants } override fun hashCode(): Int = key.hashCode() @@ -85,6 +91,7 @@ public class ConfigParam appendIfPresent(key = "description", description) appendIfPresent(key = "category", category) appendIfPresent(key = "since", since) + appendIfPresent(key = "enumConstants", enumConstants) append(')') } @@ -131,6 +138,7 @@ public inline fun ConfigParam( description: String? = null, category: String? = null, since: String? = null, + enumConstants: List? = null, ): ConfigParam = ConfigParam( key = key, @@ -139,4 +147,5 @@ public inline fun ConfigParam( description = description, category = category, since = since, + enumConstants = enumConstants, ) diff --git a/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValues.kt b/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValues.kt index ab8df1e..d0dfa68 100644 --- a/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValues.kt +++ b/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValues.kt @@ -1,13 +1,17 @@ @file:Suppress("unused") +@file:OptIn(kotlin.concurrent.atomics.ExperimentalAtomicApi::class) package dev.androidbroadcast.featured import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge +import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.update /** * Central access point for reading, overriding, and observing configuration values. @@ -27,6 +31,18 @@ import kotlinx.coroutines.flow.merge * [fetch] is **not** guarded — the caller explicitly triggers a network operation and is * responsible for handling any exceptions it throws. * + * ### Sync read path + * + * [getValueCached] reads from an in-memory snapshot without any provider I/O. The snapshot is + * populated lazily by [getValue], [override], and [fetch]. Before any of these have been called + * for a given parameter, [getValueCached] returns a [ConfigValue] with + * [ConfigValue.Source.DEFAULT] wrapping [ConfigParam.defaultValue] — matching Firebase + * Remote Config's "activate then read sync" contract. + * + * Note (Phase-1 limitation): values written directly to a [LocalConfigValueProvider] without + * going through [ConfigValues.override] bypass the snapshot and will not be visible to + * [getValueCached] until the next [getValue] or [observe] emission for that parameter. + * * ```kotlin * val configValues = ConfigValues( * localProvider = InMemoryConfigValueProvider(), @@ -37,7 +53,10 @@ import kotlinx.coroutines.flow.merge * // Load cached remote values at app start (no network call) * configValues.initialize() * - * // One-shot read — never throws due to provider failure + * // Sync read — safe from any thread; returns DEFAULT until cache is warm + * val enabled: Boolean = configValues.getValueCached(DarkModeParam).value + * + * // One-shot async read — never throws due to provider failure; also warms the cache * val value: ConfigValue = configValues.getValue(DarkModeParam) * * // Reactive observation — flow does not terminate on provider error @@ -46,6 +65,16 @@ import kotlinx.coroutines.flow.merge * } * ``` * + * ### Multi-module wiring + * + * When wiring a multi-module application, construct one [ConfigValues] per feature module so each + * module sees only the flags it declares. All [ConfigValues] instances should share the same + * [LocalConfigValueProvider] (and [RemoteConfigValueProvider], if any) — the provider is the + * single source of truth for stored overrides, and its reactive [observe] flow propagates writes + * from any [ConfigValues] instance to every other one that shares the provider. A debug screen + * that exposes every flag across modules is just one extra [ConfigValues] built from the same + * shared providers and driven by `GeneratedFeaturedRegistry.all`. + * * @param localProvider Optional provider for locally persisted overrides. * @param remoteProvider Optional provider for remote configuration values. * @param onProviderError Optional callback invoked whenever a provider throws during @@ -65,6 +94,58 @@ public class ConfigValues( private val fetchSignal = MutableSharedFlow(extraBufferCapacity = 1) + /** + * In-memory snapshot of the most recently resolved [ConfigValue] per parameter key. + * + * Key: [ConfigParam.key]. Value: [ConfigValue] as resolved at last write time. + * + * Two [ConfigParam] instances sharing the same [ConfigParam.key] map to the same snapshot + * slot; the last write wins. Within a single code-generated module keys are unique; + * cross-module key collisions are theoretically possible and documented as last-write-wins. + * + * Written via copy-on-write using [AtomicReference.update]; reads via [AtomicReference.load] + * are always consistent snapshots. Thread-safe on all KMP targets. + */ + private val snapshot = AtomicReference>>(emptyMap()) + + /** Writes [configValue] into the snapshot under [param]'s key (copy-on-write). */ + private fun writeSnapshot( + param: ConfigParam, + configValue: ConfigValue, + ) { + snapshot.update { current -> current + (param.key to configValue) } + } + + /** + * Returns the currently cached [ConfigValue] for [param] without performing any I/O. + * + * Returns a [ConfigValue] with [ConfigValue.Source.DEFAULT] wrapping [ConfigParam.defaultValue] + * until the snapshot is populated by one of: + * - [getValue] — performs an async resolution and writes through to the snapshot, + * - [fetch] — pulls fresh values from the remote provider (bulk warm-up in Phase 2), + * - [override] — sets a local override and writes through to the snapshot. + * + * **Duplicate-key semantics:** two [ConfigParam] instances with the same [ConfigParam.key] + * share one snapshot slot; the last write wins. Codegen guarantees uniqueness within a + * module; cross-module collisions are possible and intentionally handled this way. + * + * Thread-safe. Safe to call from any thread, including the Android main thread. + * + * @param param The configuration parameter to read. + * @return The cached [ConfigValue], or a [ConfigValue.Source.DEFAULT] wrapper if the cache + * has not been populated for this parameter yet. + */ + public fun getValueCached(param: ConfigParam): ConfigValue { + val cached = snapshot.load()[param.key] + @Suppress("UNCHECKED_CAST") // safe: written by writeSnapshot which enforces T at write time + return if (cached != null) { + cached as ConfigValue + } else { + @Suppress("HardcodedFlagValue") // intentional: cold-read before cache is warm returns DEFAULT + ConfigValue(param.defaultValue, ConfigValue.Source.DEFAULT) + } + } + /** * Returns the current value for [param], applying provider priority. * @@ -73,6 +154,9 @@ public class ConfigValues( * Provider exceptions are caught and forwarded to [onProviderError]; this function * never throws due to provider failure. * + * The resolved value is written through to the sync snapshot so subsequent calls to + * [getValueCached] for the same parameter reflect this result without further I/O. + * * @param param The configuration parameter to read. * @return The resolved [ConfigValue], never `null`. */ @@ -82,17 +166,25 @@ public class ConfigValues( onProviderError(error) null } - if (localValue != null) return localValue + if (localValue != null) { + writeSnapshot(param, localValue) + return localValue + } val remoteValue = remoteProvider?.runCatching { get(param) }?.getOrElse { error -> onProviderError(error) null } - if (remoteValue != null) return remoteValue + if (remoteValue != null) { + writeSnapshot(param, remoteValue) + return remoteValue + } @Suppress("HardcodedFlagValue") // intentional: this IS the provider fallback path - return ConfigValue(param.defaultValue, ConfigValue.Source.DEFAULT) + val defaultValue = ConfigValue(param.defaultValue, ConfigValue.Source.DEFAULT) + // Do not write DEFAULT into the snapshot: a later override / fetch should still win. + return defaultValue } /** @@ -100,6 +192,9 @@ public class ConfigValues( * This method is used to set a user-specific value that will take precedence over * any remote value for the specified parameter. * + * After the provider write succeeds, the new value is written through to the sync + * snapshot so [getValueCached] reflects the override immediately. + * * Usually used for testing purposes or to allow users to customize. * * @param param The configuration parameter to override. @@ -109,16 +204,30 @@ public class ConfigValues( value: T, ) { localProvider?.set(param, value) + if (localProvider != null) { + writeSnapshot(param, ConfigValue(value, ConfigValue.Source.LOCAL)) + } } /** * Clears the local override for the given parameter, so subsequent reads fall back * to remote or default values. * + * After the local override is cleared, the effective value is re-resolved synchronously + * through the full provider priority chain and written through to the sync snapshot. + * [getValueCached] reflects the new value as soon as this function returns. + * * @param param The configuration parameter whose local override should be cleared. */ public suspend fun resetOverride(param: ConfigParam) { localProvider?.resetOverride(param) + // Re-resolve via the full priority chain and write through so the snapshot converges + // to remote/default rather than staying at the stale LOCAL value. + // Explicit writeSnapshot is required because getValue intentionally does not write + // DEFAULT into the snapshot (see getValue implementation). Without this write, a + // previously overridden slot would remain stale even when both providers return null. + val resolved = getValue(param) + writeSnapshot(param, resolved) } /** @@ -126,6 +235,10 @@ public class ConfigValues( * * After this call, every [getValue] call falls back to the remote provider or * [ConfigParam.defaultValue]. Has no effect when no local provider is configured. + * + * Note: the sync snapshot is **not** cleared here. Individual param slots are updated + * lazily when [getValue] or [resetOverride] is called for each param. This is consistent + * with the fact that [ConfigValues] does not maintain a registry of all known params. */ public suspend fun clearOverrides() { localProvider?.clear() @@ -142,6 +255,10 @@ public class ConfigValues( * or when no remote provider is configured. * * Does **not** perform a network fetch; use [fetch] for that. + * + * **Phase-2 note:** bulk snapshot warm-up via `SnapshotConfigValueProvider` is not yet wired + * here. The sync snapshot remains empty after [initialize] until individual params are + * resolved via [getValue] or [observe]. */ public suspend fun initialize() { (remoteProvider as? InitializableConfigValueProvider)?.initialize() @@ -151,6 +268,10 @@ public class ConfigValues( * Fetches the latest configuration values from the remote provider and activates them. * Any active [observe] flows will re-emit the updated value for the observed parameter. * Has no effect when no remote provider is configured. + * + * **Phase-2 note:** bulk snapshot warm-up after fetch (via `SnapshotConfigValueProvider`) + * is not yet implemented. The snapshot is updated lazily per-param as [observe] or + * [getValue] callers process the [fetchSignal]. */ public suspend fun fetch() { if (remoteProvider == null) return @@ -165,12 +286,17 @@ public class ConfigValues( * - the value changes via the local provider, **or** * - [fetch] completes and the remote provider returns a new value. * + * Note: local-provider direct emissions (i.e. direct calls to the provider's own `set` + * method, bypassing [ConfigValues.override]) reach observers reactively but do **not** write + * through to the snapshot. Use [ConfigValues.override] instead of the provider's `set` if + * [getValueCached] must reflect the write. + * * @param param The configuration parameter to observe. * @return A [Flow] of [ConfigValue] for the specified parameter. */ public fun observe(param: ConfigParam): Flow> { - val localFlow = localProvider?.observe(param) - val remoteFlow = fetchSignal.map { getValue(param) } + val localFlow = localProvider?.observe(param)?.catch { e -> onProviderError(e) } + val remoteFlow = fetchSignal.map { getValue(param) }.catch { e -> onProviderError(e) } return flow> { emit(getValue(param)) diff --git a/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValuesExtensions.kt b/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValuesExtensions.kt index 035cee6..aa57d94 100644 --- a/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValuesExtensions.kt +++ b/core/src/commonMain/kotlin/dev/androidbroadcast/featured/ConfigValuesExtensions.kt @@ -58,8 +58,14 @@ public fun ConfigValues.asStateFlow( /** * Returns `true` if the Boolean configuration parameter [param] is currently enabled. * - * This is a convenience wrapper around [ConfigValues.getValue] for [Boolean] parameters, - * eliminating the need to unwrap the [ConfigValue] envelope at every call site. + * This is a **synchronous**, non-suspend read from the in-memory snapshot. It returns + * [ConfigParam.defaultValue] before the snapshot is warmed by [ConfigValues.getValue], + * [ConfigValues.fetch], or [ConfigValues.override] — matching Firebase Remote Config's + * "activate-then-read" contract. + * + * This replaces the previous `suspend` variant (breaking change). Callers that used + * `runBlocking { isEnabled(p) }` or collected inside a coroutine scope can now call it + * directly from any context. * * ```kotlin * if (configValues.isEnabled(MyFeatureParam)) { @@ -68,9 +74,10 @@ public fun ConfigValues.asStateFlow( * ``` * * @param param The Boolean configuration parameter to read. - * @return The current value of [param], or [ConfigParam.defaultValue] when no provider returns one. + * @return The cached value of [param], or [ConfigParam.defaultValue] when the snapshot has + * not been populated for this parameter yet. */ -public suspend fun ConfigValues.isEnabled(param: ConfigParam): Boolean = getValue(param).value +public fun ConfigValues.isEnabled(param: ConfigParam): Boolean = getValueCached(param).value /** * Returns a [Flow] that emits the current enabled-state for [param] and updates on every change. diff --git a/core/src/commonMain/kotlin/dev/androidbroadcast/featured/TypeConverter.kt b/core/src/commonMain/kotlin/dev/androidbroadcast/featured/TypeConverter.kt index 7c17063..db3efc5 100644 --- a/core/src/commonMain/kotlin/dev/androidbroadcast/featured/TypeConverter.kt +++ b/core/src/commonMain/kotlin/dev/androidbroadcast/featured/TypeConverter.kt @@ -50,6 +50,22 @@ public interface TypeConverter { * converter.fromString("UNKNOWN") // throws IllegalArgumentException * ``` * + * ## Compatible providers + * + * Pass the returned converter to `registerConverter` on any of these providers before the first + * read or write of an enum flag: + * + * - `DataStoreConfigValueProvider.registerConverter(enumConverter())` + * - `JavaPreferencesConfigValueProvider.registerConverter(enumConverter())` + * - `SharedPreferencesProviderConfig.registerConverter(enumConverter())` + * + * `FirebaseConfigValueProvider` handles enums automatically via reflection — no registration + * is required. + * + * **iOS caveat:** `NSUserDefaultsConfigValueProvider` does not support enums at this time — + * it has no converter API. Use a `String` flag as a workaround on iOS and convert the raw + * value to your enum manually at the call site. + * * @param T The enum class to convert. * @return A [TypeConverter] that round-trips [T] by enum constant name. */ diff --git a/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ConfigParamTest.kt b/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ConfigParamTest.kt index ac5db35..0c9484a 100644 --- a/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ConfigParamTest.kt +++ b/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ConfigParamTest.kt @@ -59,6 +59,7 @@ class ConfigParamTest { assertNull(param.description) assertNull(param.category) assertNull(param.since) + assertNull(param.enumConstants) } @Test @@ -99,4 +100,58 @@ class ConfigParamTest { assertTrue(result.contains("since='1.0'")) assertTrue(result.contains("description='desc'")) } + + @Test + fun testEnumConstantsDefaultsToNull() { + val param = ConfigParam(key = "flag", defaultValue = true) + + assertNull(param.enumConstants) + } + + @Test + fun testEqualsReturnsFalseWhenEnumConstantsDiffer() { + val paramWithConstants = + ConfigParam( + key = "flag", + defaultValue = false, + enumConstants = listOf(true, false), + ) + val paramWithoutConstants = + ConfigParam( + key = "flag", + defaultValue = false, + ) + + assertNotEquals(paramWithConstants, paramWithoutConstants) + } + + @Test + fun testEqualsReturnsTrueWhenEnumConstantsAreIdentical() { + val param1 = + ConfigParam( + key = "flag", + defaultValue = false, + enumConstants = listOf(true, false), + ) + val param2 = + ConfigParam( + key = "flag", + defaultValue = false, + enumConstants = listOf(true, false), + ) + + assertEquals(param1, param2) + } + + @Test + fun testToStringIncludesEnumConstantsWhenPresent() { + val param = + ConfigParam( + key = "flag", + defaultValue = false, + enumConstants = listOf(true, false), + ) + + assertTrue(param.toString().contains("enumConstants=")) + } } diff --git a/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ConfigValuesCachedTest.kt b/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ConfigValuesCachedTest.kt new file mode 100644 index 0000000..4000dc6 --- /dev/null +++ b/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ConfigValuesCachedTest.kt @@ -0,0 +1,209 @@ +package dev.androidbroadcast.featured + +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +/** + * Unit tests for [ConfigValues.getValueCached] — the synchronous read path — and its + * write-through wiring from [ConfigValues.getValue], [ConfigValues.override], + * [ConfigValues.resetOverride], and [ConfigValues.isEnabled]. + */ +class ConfigValuesCachedTest { + private val param = ConfigParam(key = "flag", defaultValue = false) + + // --------------------------------------------------------------------------- + // Cold-read before any warm-up + // --------------------------------------------------------------------------- + + @Test + fun `getValueCached returns DEFAULT before any warm-up`() { + val configValues = ConfigValues(localProvider = InMemoryConfigValueProvider()) + + val result = configValues.getValueCached(param) + + assertEquals(false, result.value) + assertEquals(ConfigValue.Source.DEFAULT, result.source) + } + + // --------------------------------------------------------------------------- + // Write-through from override + // --------------------------------------------------------------------------- + + @Test + fun `getValueCached returns LOCAL after override`() = + runTest { + val configValues = ConfigValues(localProvider = InMemoryConfigValueProvider()) + configValues.override(param, true) + + val result = configValues.getValueCached(param) + + assertEquals(true, result.value) + assertEquals(ConfigValue.Source.LOCAL, result.source) + } + + // --------------------------------------------------------------------------- + // Write-through from suspend getValue + // --------------------------------------------------------------------------- + + @Test + fun `getValueCached after suspend getValue reflects the resolved value`() = + runTest { + val remote = + object : RemoteConfigValueProvider { + override suspend fun fetch(activate: Boolean) = Unit + + override suspend fun get(param: ConfigParam): ConfigValue? { + @Suppress("UNCHECKED_CAST") + return if (param.key == "flag") ConfigValue(true as T, ConfigValue.Source.REMOTE) else null + } + } + val configValues = ConfigValues(remoteProvider = remote) + + // Snapshot is empty before getValue + assertEquals(ConfigValue.Source.DEFAULT, configValues.getValueCached(param).source) + + configValues.getValue(param) + + // Snapshot is now warm + val cached = configValues.getValueCached(param) + assertEquals(true, cached.value) + assertEquals(ConfigValue.Source.REMOTE, cached.source) + } + + // --------------------------------------------------------------------------- + // Write-through from remote fetch path (via observe / getValue after fetch) + // --------------------------------------------------------------------------- + + @Test + fun `getValueCached returns REMOTE after fetch and getValue`() = + runTest { + val remote = + object : RemoteConfigValueProvider { + override suspend fun fetch(activate: Boolean) = Unit + + override suspend fun get(param: ConfigParam): ConfigValue? { + @Suppress("UNCHECKED_CAST") + return ConfigValue(true as T, ConfigValue.Source.REMOTE) + } + } + val configValues = ConfigValues(remoteProvider = remote) + + configValues.fetch() + configValues.getValue(param) // warms the snapshot + + val cached = configValues.getValueCached(param) + assertEquals(true, cached.value) + assertEquals(ConfigValue.Source.REMOTE, cached.source) + } + + // --------------------------------------------------------------------------- + // resetOverride re-resolution + // --------------------------------------------------------------------------- + + @Test + fun `getValueCached after resetOverride re-resolves through priority chain`() = + runTest { + val remote = + object : RemoteConfigValueProvider { + override suspend fun fetch(activate: Boolean) = Unit + + override suspend fun get(param: ConfigParam): ConfigValue? { + @Suppress("UNCHECKED_CAST") + return if (param.key == "flag") ConfigValue(true as T, ConfigValue.Source.REMOTE) else null + } + } + val local = InMemoryConfigValueProvider() + val configValues = ConfigValues(localProvider = local, remoteProvider = remote) + + // Set a local override + configValues.override(param, false) + assertEquals(false, configValues.getValueCached(param).value) + assertEquals(ConfigValue.Source.LOCAL, configValues.getValueCached(param).source) + + // Reset the override; the local provider no longer holds a value. + configValues.resetOverride(param) + + // Re-resolve via the suspend path — this is the observable contract: after + // resetOverride, getValue re-applies the priority chain (remote wins here). + val resolved = configValues.getValue(param) + assertEquals(true, resolved.value) + assertEquals(ConfigValue.Source.REMOTE, resolved.source) + + // The write-through from getValue means getValueCached now reflects REMOTE too. + val cached = configValues.getValueCached(param) + assertEquals(true, cached.value) + assertNotEquals(ConfigValue.Source.LOCAL, cached.source) + } + + // --------------------------------------------------------------------------- + // Snapshot consistency: concurrent coroutine writes (single-threaded dispatcher) + // --------------------------------------------------------------------------- + + @Test + fun `getValueCached does not corrupt snapshot under interleaved coroutine writes`() = + runTest { + val configValues = ConfigValues(localProvider = InMemoryConfigValueProvider()) + + // Launch 100 interleaved override writes with alternating values. + // runTest runs on a single-threaded dispatcher, so this exercises interleaving + // of coroutine suspension points rather than true OS-thread parallelism. + repeat(100) { i -> + launch { + configValues.override(param, i % 2 == 0) + } + } + + // Reads during interleaved writes must not throw and must return a valid Boolean. + repeat(100) { + configValues.getValueCached(param) + } + + testScheduler.runCurrent() + + // After all coroutines complete, the snapshot must hold one of the written values + // (not an illegal state). Source must be LOCAL because override() was called. + assertEquals(ConfigValue.Source.LOCAL, configValues.getValueCached(param).source) + } + + // --------------------------------------------------------------------------- + // Duplicate-key last-write-wins semantic + // --------------------------------------------------------------------------- + + @Test + fun `last-write-wins - two ConfigParams with same key share snapshot slot`() = + runTest { + val configValues = ConfigValues(localProvider = InMemoryConfigValueProvider()) + val param1 = ConfigParam(key = "shared_key", defaultValue = false) + val param2 = ConfigParam(key = "shared_key", defaultValue = false) + + configValues.override(param1, false) + configValues.override(param2, true) // wins because it is the last write + + // Both params read from the same snapshot slot + assertEquals(true, configValues.getValueCached(param1).value) + assertEquals(true, configValues.getValueCached(param2).value) + } + + // --------------------------------------------------------------------------- + // Sync isEnabled + // --------------------------------------------------------------------------- + + @Test + fun `sync isEnabled returns false before warm-up for default=false param`() { + val configValues = ConfigValues(localProvider = InMemoryConfigValueProvider()) + + assertEquals(false, configValues.isEnabled(param)) + } + + @Test + fun `sync isEnabled returns true after override with true value`() = + runTest { + val configValues = ConfigValues(localProvider = InMemoryConfigValueProvider()) + configValues.override(param, true) + + assertEquals(true, configValues.isEnabled(param)) + } +} diff --git a/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ConfigValuesExtensionsTest.kt b/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ConfigValuesExtensionsTest.kt index 9d02287..c38c2f7 100644 --- a/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ConfigValuesExtensionsTest.kt +++ b/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ConfigValuesExtensionsTest.kt @@ -70,7 +70,9 @@ class ConfigValuesExtensionsTest { runTest { val provider = InMemoryConfigValueProvider() val configValues = ConfigValues(localProvider = provider) - provider.set(darkModeParam, true) + // Use configValues.override so the snapshot is warmed; isEnabled is now non-suspend + // and reads from the snapshot only. + configValues.override(darkModeParam, true) assertEquals(true, configValues.isEnabled(darkModeParam)) } diff --git a/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ProviderErrorObserveTest.kt b/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ProviderErrorObserveTest.kt new file mode 100644 index 0000000..f1d61c8 --- /dev/null +++ b/core/src/commonTest/kotlin/dev/androidbroadcast/featured/ProviderErrorObserveTest.kt @@ -0,0 +1,90 @@ +package dev.androidbroadcast.featured + +import app.cash.turbine.test +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ProviderErrorObserveTest { + private val testParam = ConfigParam("observe_error_key", "DEFAULT") + + /** + * Fake local provider whose [observe] emits one value successfully, + * then throws. All other required methods are no-ops. + */ + private class OnceThrowingLocalProvider( + private val emittedValue: String, + private val error: Throwable, + ) : LocalConfigValueProvider { + override suspend fun get(param: ConfigParam): ConfigValue? = null + + override suspend fun set( + param: ConfigParam, + value: T, + ) = Unit + + override suspend fun resetOverride(param: ConfigParam) = Unit + + override suspend fun clear() = Unit + + @Suppress("UNCHECKED_CAST") + override fun observe(param: ConfigParam): Flow> = + flow { + emit(ConfigValue(emittedValue as T, ConfigValue.Source.LOCAL)) + throw error + } + } + + @Test + fun observeDoesNotPropagateWhenLocalProviderFlowThrows() = + runTest { + val errors = mutableListOf() + val provider = + OnceThrowingLocalProvider( + emittedValue = "local_value", + error = IllegalStateException("simulated provider error"), + ) + val configValues = + ConfigValues( + localProvider = provider, + onProviderError = { errors.add(it) }, + ) + + val collected = mutableListOf() + + // Collection must complete without throwing; the Flow stays alive after the + // local provider's Flow terminates with an exception. + configValues.observe(testParam).test { + // Initial emission from getValue(param) — local provider's get() returns null + // so this resolves to the default value. + val initial = awaitItem() + assertEquals("DEFAULT", initial.value) + assertEquals(ConfigValue.Source.DEFAULT, initial.source) + collected.add(initial.value) + + // Emission from the local provider's observe() before it throws. + // distinctUntilChanged passes it because "local_value" ≠ "DEFAULT". + val fromLocal = awaitItem() + assertEquals("local_value", fromLocal.value) + assertEquals(ConfigValue.Source.LOCAL, fromLocal.source) + collected.add(fromLocal.value) + + cancelAndIgnoreRemainingEvents() + } + + // The thrown exception must have been routed to onProviderError, not re-thrown. + assertEquals(1, errors.size) + assertTrue( + errors[0] is IllegalStateException, + "Expected IllegalStateException but was ${errors[0]::class}", + ) + assertEquals("simulated provider error", errors[0].message) + + // Both values were collected — flow did not crash before the second emission. + assertTrue("DEFAULT" in collected) + assertTrue("local_value" in collected) + } +} diff --git a/docs/api/index.md b/docs/api/index.md deleted file mode 100644 index 6615402..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 | -| `generateProguardRules` | 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 1828e02..0000000 --- a/docs/changelog.md +++ /dev/null @@ -1,33 +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._ - ---- - -## 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 9988731..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:generateProguardRules -``` - -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 00d054a..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 generateProguardRules # 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/featured-bom/build.gradle.kts b/featured-bom/build.gradle.kts index 9bd2fff..d94ac3d 100644 --- a/featured-bom/build.gradle.kts +++ b/featured-bom/build.gradle.kts @@ -18,10 +18,8 @@ dependencies { api(project(":providers:configcat")) api(project(":featured-compose")) - api(project(":featured-registry")) api(project(":featured-debug-ui")) api(project(":featured-testing")) - api(project(":featured-gradle-plugin")) api(project(":featured-lint-rules")) api(project(":featured-platform")) diff --git a/featured-debug-ui/README.md b/featured-debug-ui/README.md index bc94558..6c59145 100644 --- a/featured-debug-ui/README.md +++ b/featured-debug-ui/README.md @@ -6,18 +6,28 @@ Ready-made Compose screen for runtime flag inspection and override. Intended for ```kotlin debugImplementation("dev.androidbroadcast.featured:featured-debug-ui") -debugImplementation("dev.androidbroadcast.featured:featured-registry") // required ``` ## Usage -Embed `FeatureFlagsDebugScreen` behind a dev-menu, shake gesture, or debug settings screen: +Embed `FeatureFlagsDebugScreen` behind a dev-menu, shake gesture, or debug settings screen. Pass an explicit `List>` as the registry. + +**Recommended — use the aggregator plugin** (multi-module projects): apply `dev.androidbroadcast.featured.application` in the app module and declare feature modules via `featuredAggregation(project(...))`. The plugin generates `GeneratedFeaturedRegistry.all` at build time. + +```kotlin +FeatureFlagsDebugScreen( + configValues = configValues, + registry = GeneratedFeaturedRegistry.all, +) +``` + +**Alternative — inline list** (small / single-module projects): ```kotlin FeatureFlagsDebugScreen( - registry = FlagRegistry.instance, configValues = configValues, + registry = listOf(MyFlags.flagA, MyFlags.flagB), ) ``` -The screen displays all registered flags grouped by category, shows the current value and its source (DEFAULT / LOCAL / REMOTE), and allows overriding any flag value in-process. Overrides are applied via `ConfigValues.override()` and survive until the app process is restarted (or `clearOverrides()` is called). +The screen displays all registry flags grouped by category, shows the current value and its source (DEFAULT / LOCAL / REMOTE), and allows overriding any flag value in-process. Overrides are applied via `ConfigValues.override()` and survive until the app process is restarted (or `clearOverrides()` is called). diff --git a/featured-debug-ui/build.gradle.kts b/featured-debug-ui/build.gradle.kts index 3ca03f4..7b2883f 100644 --- a/featured-debug-ui/build.gradle.kts +++ b/featured-debug-ui/build.gradle.kts @@ -44,7 +44,6 @@ kotlin { sourceSets { commonMain.dependencies { implementation(projects.core) - implementation(projects.featuredRegistry) implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) diff --git a/featured-debug-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/debugui/FeatureFlagsDebugScreen.kt b/featured-debug-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/debugui/FeatureFlagsDebugScreen.kt index 6d5361d..199dd88 100644 --- a/featured-debug-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/debugui/FeatureFlagsDebugScreen.kt +++ b/featured-debug-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/debugui/FeatureFlagsDebugScreen.kt @@ -14,7 +14,11 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Badge import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -38,12 +42,11 @@ import androidx.compose.ui.unit.dp import dev.androidbroadcast.featured.ConfigParam import dev.androidbroadcast.featured.ConfigValue import dev.androidbroadcast.featured.ConfigValues -import dev.androidbroadcast.featured.registry.FlagRegistry import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch /** - * A ready-to-use debug screen that lists all feature flags registered in [FlagRegistry] + * A ready-to-use debug screen that lists all feature flags in the provided [registry] * and allows toggling boolean flags or viewing current values for other types. * * Flags are grouped by [ConfigParam.category]. Each flag shows its current value, source @@ -55,7 +58,17 @@ import kotlinx.coroutines.launch * * Intended for debug/internal builds only. * + * Pass `GeneratedFeaturedRegistry.all` (from the `dev.androidbroadcast.featured.application` + * plugin) or build the list explicitly. + * * @param configValues The [ConfigValues] instance used to read and override flag values. + * @param registry The list of [ConfigParam] instances to display. Must be a stable + * reference (a top-level `val`, an `object` property, or a `remember`-ed list). + * The screen keys its internal `LaunchedEffect` on this list via `equals` (structural). + * A freshly-allocated list on every recomposition may restart the effect; prefer a + * stable top-level `val` or `object` property for predictable behavior. + * Each [ConfigParam.key] must be unique within the list; duplicates cause a + * runtime crash in `LazyColumn` key collision. * @param modifier Optional [Modifier] for the root composable. */ @OptIn(ExperimentalMaterial3Api::class) @@ -63,6 +76,7 @@ import kotlinx.coroutines.launch @Suppress("ktlint:standard:function-naming") public fun FeatureFlagsDebugScreen( configValues: ConfigValues, + registry: List>, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() @@ -70,16 +84,15 @@ public fun FeatureFlagsDebugScreen( mutableStateOf>>>(emptyMap()) } - LaunchedEffect(configValues) { - val params = FlagRegistry.all() - groupedItems = groupFlagsByCategory(buildDebugItems(configValues, params)) + LaunchedEffect(configValues, registry) { + groupedItems = groupFlagsByCategory(buildDebugItems(configValues, registry)) // Reactive: observe all params and refresh on any change. // On each emission all params are re-read — acceptable for a debug-only screen. - val flows = params.map { param -> configValues.observe(param) } + val flows = registry.map { param -> configValues.observe(param) } if (flows.isNotEmpty()) { flows.merge().collect { - groupedItems = groupFlagsByCategory(buildDebugItems(configValues, params)) + groupedItems = groupFlagsByCategory(buildDebugItems(configValues, registry)) } } } @@ -97,7 +110,7 @@ public fun FeatureFlagsDebugScreen( horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = "No feature flags registered.", + text = "No feature flags to display.", style = MaterialTheme.typography.bodyMedium, ) } @@ -146,6 +159,15 @@ public fun FeatureFlagsDebugScreen( ) } }, + onEnumSelect = { newValue -> + scope.launch { + @Suppress("UNCHECKED_CAST") + configValues.override( + item.param as ConfigParam, + newValue, + ) + } + }, onResetToDefault = { scope.launch { configValues.resetOverride(item.param) @@ -165,6 +187,7 @@ private fun FlagItemCard( item: DebugFlagItem<*>, onToggleBoolean: (Boolean) -> Unit, onScalarInput: (Any) -> Unit, + onEnumSelect: (Any) -> Unit, onResetToDefault: () -> Unit, modifier: Modifier = Modifier, ) { @@ -216,6 +239,21 @@ private fun FlagItemCard( ) } + @Suppress("UNCHECKED_CAST") + val enumConstants = item.param.enumConstants as List>? + if (enumConstants != null) { + EnumDropdown( + label = item.key, + currentValue = item.currentValue as Enum<*>, + options = enumConstants, + onSelect = onEnumSelect, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp), + ) + } + if (item.isOverridden) { HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) Row( @@ -330,3 +368,47 @@ private fun SourceBadge(source: ConfigValue.Source) { Text(text = style.label, style = MaterialTheme.typography.labelSmall) } } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +@Suppress("ktlint:standard:function-naming") +private fun EnumDropdown( + label: String, + currentValue: Enum<*>, + options: List>, + onSelect: (Any) -> Unit, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + modifier = modifier, + ) { + OutlinedTextField( + value = currentValue.name, + onValueChange = {}, + readOnly = true, + label = { Text(label) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = + Modifier + .menuAnchor(type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled = true) + .fillMaxWidth(), + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + options.forEach { option -> + DropdownMenuItem( + text = { Text(option.name) }, + onClick = { + onSelect(option) + expanded = false + }, + ) + } + } + } +} diff --git a/featured-gradle-plugin/CLAUDE.md b/featured-gradle-plugin/CLAUDE.md index a1858b2..df44b59 100644 --- a/featured-gradle-plugin/CLAUDE.md +++ b/featured-gradle-plugin/CLAUDE.md @@ -27,8 +27,7 @@ featured { |------|--------| | `resolveFeatureFlags` | `build/featured/flags.txt` | | `generateConfigParam` | `build/generated/featured/commonMain/Generated{Local,Remote}Flags.kt` + `GeneratedFlagExtensions.kt` | -| `generateFlagRegistrar` | `build/generated/featured/GeneratedFlagRegistrar.kt` | -| `generateProguardRules` | `build/featured/proguard-featured.pro` | +| `generateFeaturedProguardRules` | `build/featured/proguard-featured.pro` | | `generateIosConstVal` | iOS constant value files | | `generateXcconfig` | `build/featured/FeatureFlags.generated.xcconfig` | diff --git a/featured-gradle-plugin/README.md b/featured-gradle-plugin/README.md new file mode 100644 index 0000000..e241f7d --- /dev/null +++ b/featured-gradle-plugin/README.md @@ -0,0 +1,54 @@ +# featured-gradle-plugin + +Gradle plugin for the [Featured](../README.md) configuration management library. + +Apply it to a module and declare flags in the `featured { }` DSL block; the plugin generates +typed `ConfigParam` objects, `ConfigValues` extension functions, and R8 shrinker rules. + +## Enum flags + +Declare an enum-typed flag with the `enum(...)` DSL function: + +```kotlin +// build.gradle.kts +featured { + localFlags { + enum( + key = "checkout_variant", + typeFqn = "com.example.CheckoutVariant", + default = "LEGACY", + ) + } +} +``` + +### Runtime converter requirement (Android / JVM) + +Storage-backed local providers serialize values as strings. Before the first read or write of +an enum flag you must register an `enumConverter` on the provider, otherwise the provider +throws `IllegalArgumentException` synchronously. + +Affected providers and the required registration call: + +| Provider | Registration | +|---|---| +| `DataStoreConfigValueProvider` | `provider.registerConverter(enumConverter())` | +| `JavaPreferencesConfigValueProvider` | `provider.registerConverter(enumConverter())` | +| `SharedPreferencesProviderConfig` | `provider.registerConverter(enumConverter())` | + +`FirebaseConfigValueProvider` handles enums automatically via reflection — no registration +is needed. + +```kotlin +// Runtime wiring example (DataStore) +val provider = DataStoreConfigValueProvider(dataStore).apply { + registerConverter(enumConverter()) +} +val configValues = ConfigValues(localProvider = provider) +``` + +### iOS caveat + +`NSUserDefaultsConfigValueProvider` does not support enums at this time — it has no converter +API. Use a `String` flag as a workaround on iOS and convert the raw value to your enum manually +at the call site. diff --git a/featured-gradle-plugin/build.gradle.kts b/featured-gradle-plugin/build.gradle.kts index 827e8b4..f4628d8 100644 --- a/featured-gradle-plugin/build.gradle.kts +++ b/featured-gradle-plugin/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.kotlinJvm) + alias(libs.plugins.kotlinSerialization) `java-gradle-plugin` alias(libs.plugins.mavenPublish) } @@ -17,6 +18,10 @@ gradlePlugin { id = "dev.androidbroadcast.featured" implementationClass = "dev.androidbroadcast.featured.gradle.FeaturedPlugin" } + create("featuredApplication") { + id = "dev.androidbroadcast.featured.application" + implementationClass = "dev.androidbroadcast.featured.gradle.FeaturedApplicationPlugin" + } } } @@ -70,6 +75,7 @@ tasks.pluginUnderTestMetadata { dependencies { compileOnly("com.android.tools.build:gradle:9.1.0") + implementation(libs.kotlinx.serialization.json) // Inject AGP into the TestKit subprocess via pluginUnderTestMetadata so that the Featured // plugin can access AndroidComponentsExtension when wireProguardToVariants() is called. testPluginClasspath("com.android.tools.build:gradle:9.1.0") diff --git a/featured-gradle-plugin/settings.gradle.kts b/featured-gradle-plugin/settings.gradle.kts new file mode 100644 index 0000000..b48e411 --- /dev/null +++ b/featured-gradle-plugin/settings.gradle.kts @@ -0,0 +1,60 @@ +@file:Suppress("UnstableApiUsage") + +// pluginManagement must be the first block per Gradle's settings-script rules. + +pluginManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +// Propagate VERSION_NAME (and any other properties) from the root gradle.properties into +// this included build so that Vanniktech maven-publish resolves the correct version. +// The root gradle.properties is the single source of truth — do not duplicate VERSION_NAME here. +// +// providers.fileContents() registers the file with Gradle's configuration-cache fingerprint, +// unlike a raw FileInputStream read which is invisible to the cache. When root gradle.properties +// changes (e.g. VERSION_NAME bump), the cache entry is invalidated and the new value is picked up. +val parentPropertiesText: Provider = + providers.fileContents(layout.rootDirectory.file("../gradle.properties")).asText + +gradle.beforeProject { + val parentProps = + java.util.Properties().apply { + parentPropertiesText.orNull?.reader()?.use { load(it) } + } + parentProps.forEach { key, value -> + extensions.extraProperties[key.toString()] = value.toString() + } + // Set project.version directly so Vanniktech's providers.gradleProperty fallback also works. + (parentProps.getProperty("VERSION_NAME") ?: "unspecified").let { version = it } +} + +rootProject.name = "featured-gradle-plugin" diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGenerator.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGenerator.kt index f698f90..01e80a9 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGenerator.kt +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGenerator.kt @@ -1,34 +1,80 @@ package dev.androidbroadcast.featured.gradle /** - * Generates `GeneratedLocalFlags.kt` and `GeneratedRemoteFlags.kt` — internal objects - * containing one typed `ConfigParam` property per declared flag. + * Generates `GeneratedLocalFlags.kt` and `GeneratedRemoteFlags.kt` — internal + * objects containing one typed `ConfigParam` property per declared flag. * - * Generated example for a local Boolean flag `dark_mode`: + * Generated example for a local Boolean flag `dark_mode` in module `:sample:feature-checkout`: * ```kotlin - * internal object GeneratedLocalFlags { + * internal object GeneratedLocalFlagsSampleFeatureCheckout { * val darkMode = ConfigParam("dark_mode", false, category = "UI") * } * ``` * - * These objects are `internal` — consumers access flags exclusively through the - * generated extension functions in [ExtensionFunctionGenerator]. + * The object name and file name include a module-derived suffix (e.g. `SampleFeatureCheckout`) + * so that each module's generated class has a unique JVM name, avoiding duplicate-class errors + * when multiple modules are assembled into the same DEX or JAR. + * + * These objects are `internal` to their declaring Gradle module — a module's flag declarations + * are an implementation detail that other modules must not reference directly. Cross-module + * flag introspection goes exclusively through [GeneratedFeaturedRegistry.all], which constructs + * `ConfigParam` instances inline from manifest data without referencing these objects. */ public object ConfigParamGenerator { private const val PACKAGE = "dev.androidbroadcast.featured.generated" private const val CONFIG_PARAM_IMPORT = "dev.androidbroadcast.featured.ConfigParam" + private const val LOCAL_OBJECT_PREFIX = "GeneratedLocalFlags" + private const val REMOTE_OBJECT_PREFIX = "GeneratedRemoteFlags" + + /** + * Returns the generated object name for local flags in the given module. + * + * Examples: + * - `":app"` → `"GeneratedLocalFlagsApp"` + * - `":sample:feature-checkout"` → `"GeneratedLocalFlagsSampleFeatureCheckout"` + */ + public fun localObjectName(modulePath: String): String = "$LOCAL_OBJECT_PREFIX${modulePath.modulePathToFileSuffix()}" /** - * Generates the Kotlin source for `GeneratedLocalFlags.kt` and - * `GeneratedRemoteFlags.kt` as a pair. + * Returns the generated object name for remote flags in the given module. + * + * Examples: + * - `":app"` → `"GeneratedRemoteFlagsApp"` + * - `":sample:feature-promotions"` → `"GeneratedRemoteFlagsSampleFeaturePromotions"` + */ + public fun remoteObjectName(modulePath: String): String = "$REMOTE_OBJECT_PREFIX${modulePath.modulePathToFileSuffix()}" + + /** + * Returns the emitted `.kt` file name for the local-flags object of the given module. + * + * The object name is derived from the module path, making the JVM class name unique per module. + */ + public fun localFileName(modulePath: String): String = "${localObjectName(modulePath)}.kt" + + /** + * Returns the emitted `.kt` file name for the remote-flags object of the given module. + * + * The object name is derived from the module path, making the JVM class name unique per module. + */ + public fun remoteFileName(modulePath: String): String = "${remoteObjectName(modulePath)}.kt" + + /** + * Generates the Kotlin source for the module-specific local-flags and remote-flags objects + * as a pair. + * + * The object names and file names include a module-derived suffix (see [localObjectName] / + * [remoteObjectName]) so that each module's classes are unique at the JVM level. * * Returns a pair of `(localSource, remoteSource)`. Either may be an empty string * if there are no flags of that type. */ - public fun generate(entries: List): Pair { + public fun generate( + entries: List, + modulePath: String, + ): Pair { val (local, remote) = entries.partition { it.isLocal } - return generateObject(local, LocalFlagEntry.GENERATED_LOCAL_OBJECT) to - generateObject(remote, LocalFlagEntry.GENERATED_REMOTE_OBJECT) + return generateObject(local, localObjectName(modulePath)) to + generateObject(remote, remoteObjectName(modulePath)) } private fun generateObject( @@ -58,6 +104,7 @@ public object ConfigParamGenerator { add("defaultValue = ${formatDefault()}") if (description != null) add("description = \"$description\"") if (category != null) add("category = \"$category\"") + if (isEnum) add("enumConstants = kotlin.enums.enumEntries<$type>()") } return "ConfigParam<$typeArg>(${namedArgs.joinToString(", ")})" } diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt index cadad7a..8eb23d7 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGenerator.kt @@ -1,29 +1,36 @@ package dev.androidbroadcast.featured.gradle /** - * Generates `GeneratedFlagExtensions.kt` — public extension functions on `ConfigValues` - * for each declared flag. + * Generates the `GeneratedFlagExtensions.kt` source file — internal extension + * functions on `ConfigValues` for each declared flag. * * **Local Boolean flags** get an `is…Enabled()` extension returning the raw `Boolean`: * ```kotlin - * fun ConfigValues.isDarkModeEnabled(): Boolean = getValue(GeneratedLocalFlags.darkMode).value + * internal fun ConfigValues.isDarkModeEnabled(): Boolean = getValueCached(GeneratedLocalFlags.darkMode).value * ``` * * **Local non-Boolean flags** get a `get…()` extension returning the raw value type: * ```kotlin - * fun ConfigValues.getMaxRetries(): Int = getValue(GeneratedLocalFlags.maxRetries).value + * internal fun ConfigValues.getMaxRetries(): Int = getValueCached(GeneratedLocalFlags.maxRetries).value * ``` * * **Remote flags** get a `get…()` extension returning `ConfigValue` so callers can * inspect the value source (DEFAULT / REMOTE / etc.): * ```kotlin - * fun ConfigValues.getPromoBannerEnabled(): ConfigValue = - * getValue(GeneratedRemoteFlags.promoBannerEnabled) + * internal fun ConfigValues.getPromoBannerEnabled(): ConfigValue = + * getValueCached(GeneratedRemoteFlags.promoBannerEnabled) * ``` * - * The file uses `@file:JvmName(...)` with a module-derived suffix to guarantee unique - * JVM class names when multiple modules apply the plugin — required for accurate - * per-function ProGuard `-assumevalues` rules. + * Extensions are `internal` because no external production consumer depends on them — modules + * that need `ConfigParam` values directly use `observe(GeneratedLocalFlags.x)` against the + * `internal` generated objects within the same Gradle module. + * + * **JVM class-name uniqueness:** `@file:JvmName` is intentionally absent — it is not + * supported on Kotlin/Native targets. Instead, the emitted file is named + * `GeneratedFlagExtensions.kt` where `` is derived from the Gradle module + * path (e.g. `SampleFeatureCheckout`). The Kotlin compiler derives the JVM class name from + * the file name, so `GeneratedFlagExtensionsSampleFeatureCheckoutKt` is unique per module + * without any JVM-specific annotation. */ public object ExtensionFunctionGenerator { private const val PACKAGE = "dev.androidbroadcast.featured.generated" @@ -31,15 +38,22 @@ public object ExtensionFunctionGenerator { private const val CONFIG_VALUE_IMPORT = "dev.androidbroadcast.featured.ConfigValue" /** - * Returns the `@file:JvmName` value for the given Gradle module path. + * Returns the emitted `.kt` file name for the given Gradle module path. + * + * The file-name suffix is derived by splitting the module path on all non-alphanumeric + * characters and PascalCasing each segment, ensuring a valid Kotlin identifier without + * any JVM-specific annotation. The Kotlin compiler derives the JVM class name from this + * file name (e.g. `GeneratedFlagExtensionsSampleFeatureCheckoutKt`). * - * Examples: `":app"` → `"FeaturedApp_FlagExtensionsKt"`, - * `":feature:checkout"` → `"FeaturedFeatureCheckout_FlagExtensionsKt"`. + * Examples: + * - `":app"` → `"GeneratedFlagExtensionsApp.kt"` + * - `":feature:checkout"` → `"GeneratedFlagExtensionsFeatureCheckout.kt"` + * - `":sample:feature-checkout"` → `"GeneratedFlagExtensionsSampleFeatureCheckout.kt"` */ - public fun jvmFileName(modulePath: String): String = "Featured${modulePath.modulePathToIdentifier()}_FlagExtensionsKt" + public fun fileName(modulePath: String): String = "GeneratedFlagExtensions${modulePath.modulePathToFileSuffix()}.kt" /** - * Generates the full source text for `GeneratedFlagExtensions.kt`. + * Generates the full source text for the module-specific `GeneratedFlagExtensions.kt`. * * Returns an empty string if [entries] is empty. */ @@ -48,12 +62,12 @@ public object ExtensionFunctionGenerator { modulePath: String, ): String { if (entries.isEmpty()) return "" - val jvmName = jvmFileName(modulePath) val needsConfigValue = entries.any { !it.isLocal } + val localObjectName = ConfigParamGenerator.localObjectName(modulePath) + val remoteObjectName = ConfigParamGenerator.remoteObjectName(modulePath) return buildString { appendLine("// Auto-generated by Featured Gradle Plugin — do not edit manually.") - appendLine("@file:JvmName(\"$jvmName\")") appendLine() appendLine("package $PACKAGE") appendLine() @@ -61,28 +75,31 @@ public object ExtensionFunctionGenerator { if (needsConfigValue) appendLine("import $CONFIG_VALUE_IMPORT") val (localEntries, remoteEntries) = entries.partition { it.isLocal } if (localEntries.isNotEmpty()) { - appendLine("import $PACKAGE.${LocalFlagEntry.GENERATED_LOCAL_OBJECT}") + appendLine("import $PACKAGE.$localObjectName") } if (remoteEntries.isNotEmpty()) { - appendLine("import $PACKAGE.${LocalFlagEntry.GENERATED_REMOTE_OBJECT}") + appendLine("import $PACKAGE.$remoteObjectName") } appendLine() entries.forEach { entry -> - appendLine(entry.toExtensionFunction()) + appendLine(entry.toExtensionFunction(localObjectName, remoteObjectName)) } }.trimEnd() + "\n" } - private fun LocalFlagEntry.toExtensionFunction(): String { - val objectRef = if (isLocal) LocalFlagEntry.GENERATED_LOCAL_OBJECT else LocalFlagEntry.GENERATED_REMOTE_OBJECT + private fun LocalFlagEntry.toExtensionFunction( + localObjectName: String, + remoteObjectName: String, + ): String { + val objectRef = if (isLocal) localObjectName else remoteObjectName return if (isLocal) { val funcName = extensionFunctionName() - "public fun ConfigValues.$funcName(): $type = getValue($objectRef.$propertyName).value\n" + "internal fun ConfigValues.$funcName(): $type = getValueCached($objectRef.$propertyName).value\n" } else { // Remote flags always use get… regardless of type — the return is ConfigValue, // so callers can inspect the value source. val funcName = "get${propertyName.capitalized()}" - "public fun ConfigValues.$funcName(): ConfigValue<$type> = getValue($objectRef.$propertyName)\n" + "internal fun ConfigValues.$funcName(): ConfigValue<$type> = getValueCached($objectRef.$propertyName)\n" } } } diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedApplicationPlugin.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedApplicationPlugin.kt new file mode 100644 index 0000000..8b17388 --- /dev/null +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedApplicationPlugin.kt @@ -0,0 +1,119 @@ +package dev.androidbroadcast.featured.gradle + +import dev.androidbroadcast.featured.gradle.aggregation.FEATURED_AGGREGATION_CLASSPATH_CONFIGURATION_NAME +import dev.androidbroadcast.featured.gradle.aggregation.FEATURED_AGGREGATION_CONFIGURATION_NAME +import dev.androidbroadcast.featured.gradle.aggregation.FEATURED_REGISTRY_OBJECT +import dev.androidbroadcast.featured.gradle.aggregation.FEATURED_REGISTRY_PACKAGE +import dev.androidbroadcast.featured.gradle.aggregation.GENERATE_FEATURED_REGISTRY_TASK_NAME +import dev.androidbroadcast.featured.gradle.aggregation.GenerateFeaturedRegistryTask +import dev.androidbroadcast.featured.gradle.manifest.FEATURED_MANIFEST_USAGE +import dev.androidbroadcast.featured.gradle.manifest.SCHEMA_VERSION +import dev.androidbroadcast.featured.gradle.manifest.schemaMajorAttr +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.attributes.Usage + +/** + * Gradle plugin ID: `dev.androidbroadcast.featured.application`. + * + * Aggregates `featured-manifest.json` artifacts from all project dependencies declared via + * `featuredAggregation(project(...))` and generates a unified + * `object GeneratedFeaturedRegistry { val all: List> }` Kotlin source file. + * + * Apply this plugin alongside `dev.androidbroadcast.featured` in the application or aggregator + * module: + * ```kotlin + * plugins { + * id("dev.androidbroadcast.featured") + * id("dev.androidbroadcast.featured.application") + * } + * + * dependencies { + * featuredAggregation(project(":feature:checkout")) + * featuredAggregation(project(":feature:profile")) + * } + * ``` + * + * The generated file is written to + * `build/generated/featured/commonMain/GeneratedFeaturedRegistry.kt`. + * Wire the output directory into your source set manually — the plugin does not auto-wire + * to avoid assumptions about whether the consuming module is KMP, AGP, or plain JVM: + * ```kotlin + * kotlin.sourceSets.getByName("commonMain").kotlin.srcDir( + * tasks.named("generateFeaturedRegistry").map { it.outputs.files.singleFile.parentFile } + * ) + * ``` + * + * **Enum flag classpath requirement.** A `featuredAggregation(project(":feature:foo"))` dependency + * resolves only the `featured-manifest` Gradle variant — it does NOT put the producer's enum types + * on the consumer's compile classpath. If `:feature:foo` declares an `enum` flag whose type lives + * in `:feature:foo`'s source set, the application module must add a regular runtime dependency on + * the same module so the enum class is visible at compile time: + * ```kotlin + * dependencies { + * featuredAggregation(project(":feature:foo")) + * implementation(project(":feature:foo")) // required for enum flag types + * } + * ``` + * For modules that declare only primitive flags (Boolean / Int / Long / Float / Double / String), + * the `featuredAggregation` line alone is sufficient. + * + * Min Gradle version: 8.5+ (`configurations.dependencyScope()` / `.resolvable()` API). + */ +@Suppress("UnstableApiUsage") +internal class FeaturedApplicationPlugin : Plugin { + override fun apply(target: Project) { + // Register the schemaMajorAttr in the project's attribute schema. This is idempotent — + // if FeaturedPlugin is also applied it calls the same registration first. + target.dependencies.attributesSchema.attribute(schemaMajorAttr) + + // User-facing declarable scope: consumers add project() dependencies here. + val declarable = + target.configurations.dependencyScope( + FEATURED_AGGREGATION_CONFIGURATION_NAME, + ) { cfg -> + cfg.description = + "Project dependencies whose featured-manifest.json should be aggregated into GeneratedFeaturedRegistry." + } + + // Internal resolvable classpath that carries the attribute contract used by Gradle's + // variant selection to match the `featuredManifest` consumable configuration published + // by each producer module applying `dev.androidbroadcast.featured`. + val classpath = + target.configurations.resolvable( + FEATURED_AGGREGATION_CLASSPATH_CONFIGURATION_NAME, + ) { cfg -> + cfg.description = + "Internal classpath resolving featured-manifest.json artifacts from featuredAggregation." + cfg.extendsFrom(declarable.get()) + cfg.attributes { attrs -> + attrs.attribute( + Usage.USAGE_ATTRIBUTE, + target.objects.named(Usage::class.java, FEATURED_MANIFEST_USAGE), + ) + // Mirror the schema-major attribute declared on the producer side so that Gradle's + // variant selection picks exactly the schema-v1 manifests. + attrs.attribute(schemaMajorAttr, SCHEMA_VERSION) + } + } + + target.tasks.register( + GENERATE_FEATURED_REGISTRY_TASK_NAME, + GenerateFeaturedRegistryTask::class.java, + ) { task -> + task.group = "featured" + task.description = + "Aggregates featured-manifest.json artifacts and generates GeneratedFeaturedRegistry.kt." + // Lazy artifact view — resolved at execution time, CC-compatible. + task.manifestFiles.from( + classpath.map { it.incoming.artifactView { view -> view.isLenient = false }.files }, + ) + task.outputPackage.set(FEATURED_REGISTRY_PACKAGE) + task.outputFile.convention( + target.layout.buildDirectory.file( + "generated/featured/commonMain/${FEATURED_REGISTRY_OBJECT}.kt", + ), + ) + } + } +} diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt index 6496396..b331e8b 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt @@ -1,13 +1,19 @@ package dev.androidbroadcast.featured.gradle +import dev.androidbroadcast.featured.gradle.manifest.FEATURED_MANIFEST_CONFIGURATION_NAME +import dev.androidbroadcast.featured.gradle.manifest.FEATURED_MANIFEST_USAGE +import dev.androidbroadcast.featured.gradle.manifest.GENERATE_FEATURED_MANIFEST_TASK_NAME +import dev.androidbroadcast.featured.gradle.manifest.GenerateFeaturedManifestTask +import dev.androidbroadcast.featured.gradle.manifest.SCHEMA_VERSION +import dev.androidbroadcast.featured.gradle.manifest.schemaMajorAttr import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.api.attributes.Usage import org.gradle.api.tasks.TaskProvider internal const val RESOLVE_FLAGS_TASK_NAME = "resolveFeatureFlags" internal const val SCAN_ALL_TASK_NAME = "scanAllLocalFlags" -internal const val GENERATE_FLAG_REGISTRAR_TASK_NAME = "generateFlagRegistrar" -internal const val GENERATE_PROGUARD_TASK_NAME = "generateProguardRules" +internal const val GENERATE_PROGUARD_TASK_NAME = "generateFeaturedProguardRules" internal const val GENERATE_IOS_CONST_VAL_TASK_NAME = "generateIosConstVal" internal const val GENERATE_XCCONFIG_TASK_NAME = "generateXcconfig" internal const val GENERATE_CONFIG_PARAM_TASK_NAME = "generateConfigParam" @@ -17,8 +23,7 @@ internal const val GENERATE_CONFIG_PARAM_TASK_NAME = "generateConfigParam" * 1. Exposes the `featured { }` DSL extension for declaring local and remote feature flags. * 2. Generates typed `ConfigParam` objects and ergonomic `ConfigValues` extension functions. * 3. Generates per-function R8 `-assumevalues` rules for local flags (dead-code elimination). - * 4. Generates a `GeneratedFlagRegistrar` that registers all flags with `FlagRegistry`. - * 5. Generates iOS constant-value files and xcconfig for Swift dead-code elimination. + * 4. Generates iOS constant-value files and xcconfig for Swift dead-code elimination. * * Usage in `build.gradle.kts`: * ```kotlin @@ -50,10 +55,11 @@ public class FeaturedPlugin : Plugin { } registerConfigParamTask(target, resolveTask) - registerFlagRegistrarTask(target, resolveTask) val proguardTask = registerProguardTask(target, resolveTask) registerIosConstValTask(target, resolveTask) registerXcconfigTask(target, resolveTask) + val manifestTask = registerManifestTask(target, resolveTask) + registerFeaturedManifestConfiguration(target, manifestTask) wireToRootAggregator(target, resolveTask) listOf("com.android.application", "com.android.library").forEach { pluginId -> target.plugins.withId(pluginId) { @@ -85,22 +91,6 @@ public class FeaturedPlugin : Plugin { } } - private fun registerFlagRegistrarTask( - target: Project, - resolveTask: TaskProvider, - ) { - target.tasks.register(GENERATE_FLAG_REGISTRAR_TASK_NAME, GenerateFlagRegistrarTask::class.java) { task -> - task.group = "featured" - task.description = "Generates GeneratedFlagRegistrar.kt for '${target.path}'." - task.scanResultFile.set(resolveTask.flatMap { it.outputFile }) - task.packageName.set("dev.androidbroadcast.featured.generated") - task.outputFile.set( - target.layout.buildDirectory.file("generated/featured/GeneratedFlagRegistrar.kt"), - ) - task.dependsOn(resolveTask) - } - } - private fun registerProguardTask( target: Project, resolveTask: TaskProvider, @@ -145,6 +135,78 @@ public class FeaturedPlugin : Plugin { } } + private fun registerManifestTask( + target: Project, + resolveTask: TaskProvider, + ): TaskProvider = + target.tasks.register( + GENERATE_FEATURED_MANIFEST_TASK_NAME, + GenerateFeaturedManifestTask::class.java, + ) { task -> + task.group = "featured" + task.description = "Generates featured-manifest.json for '${target.path}'." + task.flagsFile.set(resolveTask.flatMap { it.outputFile }) + // Snapshot target.path at configuration time — Project must not be captured by + // task state to remain Configuration Cache compliant. + task.modulePath.set(target.path) + task.outputFile.convention( + target.layout.buildDirectory.file("featured/featured-manifest.json"), + ) + task.dependsOn(resolveTask) + } + + private fun registerFeaturedManifestConfiguration( + target: Project, + manifestTask: TaskProvider, + ) { + // Register the schemaMajorAttr in the project's attribute schema so that Gradle's + // dependency resolution can match it precisely between producer and consumer. + target.dependencies.attributesSchema.attribute(schemaMajorAttr) + + val manifestConfiguration = + target.configurations.consumable( + FEATURED_MANIFEST_CONFIGURATION_NAME, + ) { config -> + config.attributes { + it.attribute( + Usage.USAGE_ATTRIBUTE, + target.objects.named(Usage::class.java, FEATURED_MANIFEST_USAGE), + ) + // Use SCHEMA_VERSION constant — not a hardcoded literal — so that a future bump + // automatically flows through to the attribute without a separate edit here. + it.attribute(schemaMajorAttr, SCHEMA_VERSION) + } + } + + // Wire the manifest file as an outgoing artifact. The provider chain already carries + // the task dependency; builtBy is explicit for IDE / --dry-run readability. + target.artifacts.add( + FEATURED_MANIFEST_CONFIGURATION_NAME, + manifestTask.flatMap { it.outputFile }, + ) { artifact -> + artifact.builtBy(manifestTask) + } + + // Maven-publish guard intentionally omitted (verified 2026-05-18 via KMP smoke test). + // + // The `java`, `java-library`, `kotlinMultiplatform`, and `com.android.library` software + // components do NOT auto-publish arbitrary consumable configurations. Each component + // exposes only the variants it explicitly added via `addVariantsFromConfiguration` — + // typically `apiElements` / `runtimeElements` for Java, target-specific + // `*ApiElements` / `*RuntimeElements` for KMP, build-type variants for AGP. + // + // The `featuredManifest` configuration is never registered with any of these components, + // so it does not appear in published Maven metadata. A guard via + // `withVariantsFromConfiguration(...) { skip() }` is not only unnecessary — it actively + // throws `Variant for configuration 'featuredManifest' does not exist in component` + // during publication because `withVariantsFromConfiguration` requires the variant to + // have been added first. + // + // The KMP smoke fixture (`kmp-publish-project`) and `FeaturedKmpPublicationTest` verify + // this invariant: a KMP module that applies both `dev.androidbroadcast.featured` and + // `maven-publish` produces module metadata with no `featured-manifest` Usage variant. + } + /** * Ensures the root project has a `scanAllLocalFlags` aggregation task and wires * [resolveTask] into it. `./gradlew scanAllLocalFlags` triggers flag resolution diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagContainer.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagContainer.kt index 5003422..994a516 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagContainer.kt +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagContainer.kt @@ -84,8 +84,49 @@ public class FlagContainer { /** * Declares an enum-typed feature flag. * - * Enum flags are intentionally excluded from R8 `-assumevalues` DCE rules — the value - * cannot be assumed at build time (it is resolved at runtime from providers). + * The plugin generates a typed `ConfigParam` backed by this declaration. Enum flags are + * intentionally excluded from R8 `-assumevalues` DCE rules — the value cannot be assumed at + * build time (it is resolved at runtime from providers). + * + * ## Runtime converter requirement (Android / JVM) + * + * Storage-backed local providers serialize values as strings and require an explicit + * [enumConverter] registration before the first read or write of this flag. Without it the + * provider throws [IllegalArgumentException] synchronously. Affected providers: + * + * - `DataStoreConfigValueProvider` + * - `JavaPreferencesConfigValueProvider` + * - `SharedPreferencesProviderConfig` + * + * Firebase Remote Config (`FirebaseConfigValueProvider`) handles enums automatically via + * reflection — no `registerConverter` call is needed there. + * + * **iOS caveat:** `NSUserDefaultsConfigValueProvider` does not support enums at this time — + * it has no converter API. Use a `String` flag as a workaround on iOS and convert the raw + * value to your enum manually at the call site. + * + * ## Example + * + * ```kotlin + * // Gradle DSL — declaration + * featured { + * localFlags { + * enum( + * key = "checkout_variant", + * typeFqn = "com.example.CheckoutVariant", + * default = "LEGACY", + * ) + * } + * } + * ``` + * + * ```kotlin + * // Runtime — required wiring for non-Firebase local providers + * val provider = DataStoreConfigValueProvider(dataStore).apply { + * registerConverter(enumConverter()) + * } + * val configValues = ConfigValues(localProvider = provider) + * ``` * * @param key The configuration key string (e.g. `"checkout_variant"`). * @param typeFqn The fully-qualified Kotlin class name of the enum (e.g. `"com.example.CheckoutVariant"`). diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtils.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtils.kt index b1f93fe..029ae22 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtils.kt +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtils.kt @@ -15,8 +15,11 @@ internal fun String.toCamelCase(): String = }.joinToString("") /** - * Derives a unique JVM class name suffix from a Gradle module path, used in - * `@file:JvmName(...)` to prevent class name conflicts across modules. + * Derives a PascalCase identifier from a Gradle module path. + * + * Splits on `:` only; segments containing hyphens or other special characters are + * preserved as single PascalCase words (e.g. `"feature-checkout"` → `"Feature-checkout"`). + * Used internally for identifier derivation. * * Examples: * - `":app"` → `"App"` @@ -30,6 +33,26 @@ internal fun String.modulePathToIdentifier(): String = .joinToString("") { segment -> segment.replaceFirstChar { it.uppercase() } } .ifEmpty { "Root" } +/** + * Derives a PascalCase file-name suffix from a Gradle module path, safe for use as a + * Kotlin source-file name component. + * + * Unlike [modulePathToIdentifier], this function splits on ALL non-alphanumeric characters + * (`:`, `-`, `.`, `_`, etc.) so that path segments like `"feature-checkout"` produce + * `"FeatureCheckout"` rather than `"Feature-checkout"`. + * + * Examples: + * - `":app"` → `"App"` + * - `":feature:checkout"` → `"FeatureCheckout"` + * - `":sample:feature-checkout"` → `"SampleFeatureCheckout"` + * - `":"` → `"Root"` + */ +internal fun String.modulePathToFileSuffix(): String = + split(Regex("[^A-Za-z0-9]+")) + .filter { it.isNotBlank() } + .joinToString("") { segment -> segment.replaceFirstChar { it.uppercase() } } + .ifEmpty { "Root" } + internal fun String.capitalized(): String = replaceFirstChar { it.uppercase() } /** diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagRegistrarGenerator.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagRegistrarGenerator.kt deleted file mode 100644 index 72c1296..0000000 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FlagRegistrarGenerator.kt +++ /dev/null @@ -1,65 +0,0 @@ -package dev.androidbroadcast.featured.gradle - -/** - * Generates `GeneratedFlagRegistrar.kt` — an object with a `register()` function that - * calls `FlagRegistry.register(...)` for every flag declared in this module. - * - * The generated references point to the objects produced by [ConfigParamGenerator]: - * ```kotlin - * public object GeneratedFlagRegistrar { - * public fun register() { - * FlagRegistry.register(GeneratedLocalFlags.darkMode) - * FlagRegistry.register(GeneratedRemoteFlags.newCheckout) - * } - * } - * ``` - * - * This file is KMP-safe: it imports only `dev.androidbroadcast.featured.registry.FlagRegistry` - * which is available in `commonMain`. - */ -public object FlagRegistrarGenerator { - private const val FLAG_REGISTRY_IMPORT = "dev.androidbroadcast.featured.registry.FlagRegistry" - private const val LOCAL_FLAGS_IMPORT = - "dev.androidbroadcast.featured.generated.${LocalFlagEntry.GENERATED_LOCAL_OBJECT}" - private const val REMOTE_FLAGS_IMPORT = - "dev.androidbroadcast.featured.generated.${LocalFlagEntry.GENERATED_REMOTE_OBJECT}" - - /** - * Generates the `GeneratedFlagRegistrar.kt` source text. - * - * When [entries] is empty the `register()` body is empty but the file is still emitted - * so the compilation source set always contains a valid symbol. - * - * @param entries All flag entries for this module (local + remote). - * @param packageName Package for the generated file. - */ - public fun generate( - entries: List, - packageName: String = "dev.androidbroadcast.featured.generated", - ): String = - buildString { - appendLine("// Auto-generated by Featured Gradle Plugin — do not edit manually.") - appendLine("package $packageName") - appendLine() - appendLine("import $FLAG_REGISTRY_IMPORT") - if (entries.any { it.isLocal }) appendLine("import $LOCAL_FLAGS_IMPORT") - if (entries.any { !it.isLocal }) appendLine("import $REMOTE_FLAGS_IMPORT") - appendLine() - appendLine("public object GeneratedFlagRegistrar {") - appendLine(" /**") - appendLine(" * Registers all flags declared in this module with [FlagRegistry].") - appendLine(" * Call this once during app startup.") - appendLine(" */") - appendLine(" public fun register() {") - entries.forEach { entry -> - val ref = entry.kotlinReference - if (ref.isNotBlank()) { - appendLine(" FlagRegistry.register($ref)") - } else { - appendLine(" // TODO: register flag '${entry.key}' — property reference unavailable") - } - } - appendLine(" }") - append("}") - } -} diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateConfigParamTask.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateConfigParamTask.kt index 624e787..3704eb7 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateConfigParamTask.kt +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateConfigParamTask.kt @@ -15,9 +15,11 @@ import org.gradle.api.tasks.TaskAction /** * Gradle task that reads the [ResolveFlagsTask] output and generates three Kotlin source files: * - * - `GeneratedLocalFlags.kt` — internal object with one `ConfigParam` per local flag. - * - `GeneratedRemoteFlags.kt` — internal object with one `ConfigParam` per remote flag. - * - `GeneratedFlagExtensions.kt` — public extension functions on `ConfigValues`, one per flag. + * - `GeneratedLocalFlags.kt` — public object with one `ConfigParam` per local flag. + * - `GeneratedRemoteFlags.kt` — public object with one `ConfigParam` per remote flag. + * - `GeneratedFlagExtensions.kt` — internal extension functions on `ConfigValues`, + * one per flag. The suffix is derived from [modulePath] (e.g. `SampleFeatureCheckout`) + * so that each module's file produces a unique JVM class name. * * All files are written to [outputDir] (`build/generated/featured/commonMain/`). * Add [outputDir] to the Kotlin compilation source set: @@ -36,7 +38,7 @@ public abstract class GenerateConfigParamTask : DefaultTask() { @get:PathSensitive(PathSensitivity.NONE) public abstract val flagsFile: RegularFileProperty - /** The Gradle module path used to derive the `@file:JvmName` suffix. */ + /** The Gradle module path (e.g. `":sample:feature-checkout"`) used to derive the file-name suffix. */ @get:Input public abstract val modulePath: Property @@ -48,19 +50,24 @@ public abstract class GenerateConfigParamTask : DefaultTask() { public fun generate() { val entries = flagsFile.parseLocalFlagEntries() val dir = outputDir.get().asFile + // Clean before writing — the extension file name changed from the fixed + // "GeneratedFlagExtensions.kt" to a module-specific name, so stale files + // from previous runs must be removed to avoid duplicate-class compile errors. + dir.deleteRecursively() dir.mkdirs() - val (localSource, remoteSource) = ConfigParamGenerator.generate(entries) - val extensionsSource = ExtensionFunctionGenerator.generate(entries, modulePath.get()) + val path = modulePath.get() + val (localSource, remoteSource) = ConfigParamGenerator.generate(entries, path) + val extensionsSource = ExtensionFunctionGenerator.generate(entries, path) if (localSource.isNotEmpty()) { - dir.resolve("GeneratedLocalFlags.kt").writeText(localSource) + dir.resolve(ConfigParamGenerator.localFileName(path)).writeText(localSource) } if (remoteSource.isNotEmpty()) { - dir.resolve("GeneratedRemoteFlags.kt").writeText(remoteSource) + dir.resolve(ConfigParamGenerator.remoteFileName(path)).writeText(remoteSource) } if (extensionsSource.isNotEmpty()) { - dir.resolve("GeneratedFlagExtensions.kt").writeText(extensionsSource) + dir.resolve(ExtensionFunctionGenerator.fileName(modulePath.get())).writeText(extensionsSource) } val local = entries.count { it.isLocal } diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateFlagRegistrarTask.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateFlagRegistrarTask.kt deleted file mode 100644 index 8917582..0000000 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/GenerateFlagRegistrarTask.kt +++ /dev/null @@ -1,70 +0,0 @@ -package dev.androidbroadcast.featured.gradle - -import org.gradle.api.DefaultTask -import org.gradle.api.file.RegularFileProperty -import org.gradle.api.provider.Property -import org.gradle.api.tasks.CacheableTask -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.InputFile -import org.gradle.api.tasks.OutputFile -import org.gradle.api.tasks.PathSensitive -import org.gradle.api.tasks.PathSensitivity -import org.gradle.api.tasks.TaskAction - -/** - * Gradle task that reads the [ScanLocalFlagsTask] output file and generates a - * `GeneratedFlagRegistrar.kt` source file containing an `object GeneratedFlagRegistrar` - * with a single `register()` function that calls `FlagRegistry.register(...)` for every - * `@LocalFlag`-annotated `ConfigParam` in this module. - * - * The generated file is KMP-safe — it uses only APIs available in `commonMain`. - * - * Wire the generated source directory into the Kotlin compilation manually: - * ```kotlin - * kotlin { - * sourceSets.commonMain.get().kotlin.srcDir( - * tasks.named("generateFlagRegistrar").map { it.outputFile.get().asFile.parentFile } - * ) - * } - * ``` - */ -@CacheableTask -public abstract class GenerateFlagRegistrarTask : DefaultTask() { - /** - * The line-delimited flag report produced by [ScanLocalFlagsTask]. - * Each line has the format `key|defaultValue|type|moduleName|propertyName|ownerName`. - */ - @get:InputFile - @get:PathSensitive(PathSensitivity.NONE) - public abstract val scanResultFile: RegularFileProperty - - /** - * Kotlin package name used in the generated `GeneratedFlagRegistrar` object. - * Defaults to `"dev.androidbroadcast.featured.generated"`. - */ - @get:Input - public abstract val packageName: Property - - /** - * The generated `GeneratedFlagRegistrar.kt` file. - * Written to `/build/generated/featured/GeneratedFlagRegistrar.kt`. - */ - @get:OutputFile - public abstract val outputFile: RegularFileProperty - - @TaskAction - public fun generate() { - val entries = scanResultFile.parseLocalFlagEntries() - val source = FlagRegistrarGenerator.generate(entries, packageName.get()) - - val out = outputFile.get().asFile - out.parentFile?.mkdirs() - out.writeText(source) - - if (entries.isEmpty()) { - logger.lifecycle("[featured] No flags declared in featured { } DSL — GeneratedFlagRegistrar.register() is empty.") - } else { - logger.lifecycle("[featured] Generated FlagRegistrar with ${entries.size} registration(s) → ${out.path}") - } - } -} diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntry.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntry.kt index 503b09a..7ea04ca 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntry.kt +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntry.kt @@ -38,25 +38,8 @@ public data class LocalFlagEntry( */ public val isEnum: Boolean get() = '.' in type - /** - * Returns the Kotlin reference used in the generated `FlagRegistry.register(...)` call. - * - * - Local flags: `"GeneratedLocalFlags.propertyName"` - * - Remote flags: `"GeneratedRemoteFlags.propertyName"` - * - Blank when [propertyName] is empty (legacy data without property information). - */ - public val kotlinReference: String - get() = - when { - propertyName.isBlank() -> "" - isLocal -> "$GENERATED_LOCAL_OBJECT.$propertyName" - else -> "$GENERATED_REMOTE_OBJECT.$propertyName" - } - public companion object { public const val FLAG_TYPE_LOCAL: String = "local" public const val FLAG_TYPE_REMOTE: String = "remote" - internal const val GENERATED_LOCAL_OBJECT = "GeneratedLocalFlags" - internal const val GENERATED_REMOTE_OBJECT = "GeneratedRemoteFlags" } } diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGenerator.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGenerator.kt index d1961a3..0cad793 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGenerator.kt +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGenerator.kt @@ -9,7 +9,7 @@ package dev.androidbroadcast.featured.gradle * * Example output for a Boolean flag `dark_mode = false` in module `:feature:ui`: * ```proguard - * -assumevalues class dev.androidbroadcast.featured.generated.FeaturedFeatureUi_FlagExtensionsKt { + * -assumevalues class dev.androidbroadcast.featured.generated.GeneratedFlagExtensionsFeatureUiKt { * boolean isDarkModeEnabled(dev.androidbroadcast.featured.ConfigValues) return false; * } * ``` @@ -31,7 +31,9 @@ public object ProguardRulesGenerator { * Generates ProGuard `-assumevalues` rules for all local flags in [entries]. * * [modulePath] is the Gradle module path (e.g. `":feature:ui"`) used to derive - * the JVM class name of the generated extensions file via [ExtensionFunctionGenerator.jvmFileName]. + * the JVM class name of the generated extensions file. The class name is derived from + * [ExtensionFunctionGenerator.fileName]: the Kotlin compiler uses the file name + * (without `.kt`) plus the `Kt` suffix as the JVM class name for top-level declarations. * * Returns a blank string when [entries] contains no local flags with a supported type. */ @@ -42,7 +44,7 @@ public object ProguardRulesGenerator { val localEntries = entries.filter { it.isLocal && jvmType(it.type) != null } if (localEntries.isEmpty()) return "" - val className = "$PACKAGE.${ExtensionFunctionGenerator.jvmFileName(modulePath)}" + val className = "$PACKAGE.${ExtensionFunctionGenerator.fileName(modulePath).removeSuffix(".kt")}Kt" return buildString { appendLine("# Auto-generated by Featured Gradle Plugin — do not edit manually.") diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ResolveFlagsTask.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ResolveFlagsTask.kt index 6f195f0..dbe2fae 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ResolveFlagsTask.kt +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/ResolveFlagsTask.kt @@ -18,8 +18,8 @@ import org.gradle.api.tasks.TaskAction * * where `propertyName` is the camelCase conversion of `key` (e.g. `dark_mode` → `darkMode`). * - * Downstream tasks ([GenerateFlagRegistrarTask], [GenerateProguardRulesTask], - * [GenerateConfigParamTask], etc.) declare [outputFile] as their `@InputFile` to + * Downstream tasks ([GenerateProguardRulesTask], [GenerateConfigParamTask], etc.) + * declare [outputFile] as their `@InputFile` to * establish a proper task dependency and enable configuration-cache compatibility. */ @CacheableTask diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/AggregationContract.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/AggregationContract.kt new file mode 100644 index 0000000..4207435 --- /dev/null +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/AggregationContract.kt @@ -0,0 +1,36 @@ +package dev.androidbroadcast.featured.gradle.aggregation + +/** + * Name of the user-facing declarable Gradle configuration. + * Consumers add dependencies here via `featuredAggregation(project(...))`. + * Used by [FeaturedApplicationPlugin] to create the dependency scope. + */ +internal const val FEATURED_AGGREGATION_CONFIGURATION_NAME = "featuredAggregation" + +/** + * Name of the internal resolvable Gradle configuration. + * Extends [FEATURED_AGGREGATION_CONFIGURATION_NAME] and carries the attribute contract + * (`Usage = "featured-manifest"`, `schema-major = 1`) that Gradle uses to select the + * `featuredManifest` outgoing variant from each producer module. + */ +internal const val FEATURED_AGGREGATION_CLASSPATH_CONFIGURATION_NAME = "featuredAggregationClasspath" + +/** + * Task name registered by [FeaturedApplicationPlugin]. + * Running `./gradlew generateFeaturedRegistry` collects all manifests and writes the + * generated Kotlin source to the output file. + */ +internal const val GENERATE_FEATURED_REGISTRY_TASK_NAME = "generateFeaturedRegistry" + +/** + * Package name emitted at the top of the generated `GeneratedFeaturedRegistry.kt` file. + * Matches the package used by other Featured-generated sources in `commonMain`. + */ +internal const val FEATURED_REGISTRY_PACKAGE = "dev.androidbroadcast.featured.generated" + +/** + * Simple name of the generated Kotlin object and the output file (without `.kt` extension). + * Used both as the object identifier in the generated source and as the output filename by + * [GenerateFeaturedRegistryTask]. + */ +internal const val FEATURED_REGISTRY_OBJECT = "GeneratedFeaturedRegistry" diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTask.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTask.kt new file mode 100644 index 0000000..87acb53 --- /dev/null +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTask.kt @@ -0,0 +1,233 @@ +package dev.androidbroadcast.featured.gradle.aggregation + +import dev.androidbroadcast.featured.gradle.manifest.FeaturedManifest +import dev.androidbroadcast.featured.gradle.manifest.FeaturedManifestJson +import dev.androidbroadcast.featured.gradle.manifest.ValueType +import kotlinx.serialization.decodeFromString +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +private val PACKAGE_NAME_REGEX = Regex("[a-zA-Z][a-zA-Z0-9_]*(\\.[a-zA-Z][a-zA-Z0-9_]*)*") + +// Accepted grammar for ENUM descriptor fields interpolated verbatim into generated Kotlin source. +// Untrusted manifest content from a malicious project dependency can inject Kotlin source via +// ENUM FQN or constant name — we reject anything that does not match before calling the generator. +private val KOTLIN_FQN_REGEX = Regex("[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)*") +private val KOTLIN_IDENTIFIER_REGEX = Regex("[A-Za-z_][A-Za-z0-9_]*") + +// Accepted grammars for primitive defaultValue fields interpolated verbatim into the generated +// Kotlin source. Malicious manifests can embed arbitrary Kotlin by supplying e.g. an INT value +// that contains method-call suffixes or a BOOLEAN value with an extra statement appended. +// Each regex matches only the literal forms that Kotlin accepts for the respective numeric type. +private val BOOLEAN_LITERAL_REGEX = Regex("true|false") +private val INT_LITERAL_REGEX = Regex("-?\\d+") +private val LONG_LITERAL_REGEX = Regex("-?\\d+") +private val FLOAT_LITERAL_REGEX = Regex("-?\\d+(\\.\\d+)?([eE]-?\\d+)?") +private val DOUBLE_LITERAL_REGEX = Regex("-?\\d+(\\.\\d+)?([eE]-?\\d+)?") + +/** + * Aggregates `featured-manifest.json` files from all project dependencies declared via + * `featuredAggregation(...)` and generates `GeneratedFeaturedRegistry.kt`. + * + * Registered by [FeaturedApplicationPlugin] under the name `generateFeaturedRegistry`. + * + * Validation: duplicate flag keys across modules (including LOCAL + REMOTE of the same module) + * are rejected with an [IllegalStateException] naming both conflicting module paths. + */ +@CacheableTask +internal abstract class GenerateFeaturedRegistryTask : DefaultTask() { + /** + * The set of `featured-manifest.json` files resolved from `featuredAggregationClasspath`. + * + * [PathSensitivity.NONE] is used because only the file content matters for cache-key + * computation — the artifact path varies across machines and build cache entries. + */ + @get:InputFiles + @get:PathSensitive(PathSensitivity.NONE) + abstract val manifestFiles: ConfigurableFileCollection + + /** + * Package name written to the top of the generated source file. + * Defaults to [FEATURED_REGISTRY_PACKAGE]. + */ + @get:Input + abstract val outputPackage: Property + + /** + * Destination for the generated `GeneratedFeaturedRegistry.kt` source file. + * Convention: `build/generated/featured/commonMain/GeneratedFeaturedRegistry.kt`. + */ + @get:OutputFile + abstract val outputFile: RegularFileProperty + + @TaskAction + fun generate() { + val pkg = outputPackage.get() + require(PACKAGE_NAME_REGEX.matches(pkg)) { + "outputPackage '$pkg' is not a valid Kotlin package name." + } + + val manifests = + manifestFiles.files + .map { file -> + try { + FeaturedManifestJson.decodeFromString(file.readText()) + } catch (e: Exception) { + throw IllegalStateException( + "Failed to read or parse Featured manifest at '${file.path}': ${e.message}", + e, + ) + } + } + + validateUniqueKeys(manifests) + validateFlagDescriptorIntegrity(manifests) + + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = manifests, + packageName = pkg, + ) + + val outFile = outputFile.get().asFile + outFile.parentFile.mkdirs() + outFile.writeText(source) + + val totalFlags = manifests.sumOf { it.flags.size } + logger.lifecycle( + "[featured] Generated registry with $totalFlags flag(s) from ${manifests.size} module(s) → ${outFile.path}", + ) + } +} + +/** + * Validates that no two [FlagDescriptor][dev.androidbroadcast.featured.gradle.manifest.FlagDescriptor] + * entries across all [manifests] share the same key. + * + * A flag declared in both `localFlags` and `remoteFlags` of the same module is treated as a + * duplicate because each key produces exactly one `ConfigParam` in the registry. + * + * All duplicate keys are collected and reported in a single [IllegalStateException] so that + * every conflict is visible without requiring repeated build invocations. Each origin includes + * both the module path and the [FlagKind] so same-module LOCAL/REMOTE collisions are + * distinguishable from cross-module collisions. + * + * Manifests are sorted by [FeaturedManifest.modulePath] internally before processing so that + * the duplicate error message lists origins in a deterministic order regardless of the order + * in which Gradle resolves manifest artifacts. + * + * @throws IllegalStateException listing every duplicate key and all conflicting origins. + */ +internal fun validateUniqueKeys(manifests: List) { + val triples = + manifests + .sortedBy { it.modulePath } + .flatMap { manifest -> + manifest.flags.map { flag -> Triple(flag.key, manifest.modulePath, flag.kind) } + } + + // Collect every key that appears more than once, together with all its origins. + val duplicates = + triples + .groupBy { (key, _, _) -> key } + .filter { (_, entries) -> entries.size > 1 } + + if (duplicates.isEmpty()) return + + val message = + buildString { + appendLine("Duplicate flag keys detected in aggregated Featured manifests:") + duplicates.forEach { (key, entries) -> + val origins = entries.joinToString(", ") { (_, path, kind) -> "'$path' ($kind)" } + appendLine(" - '$key': declared in $origins") + } + } + throw IllegalStateException(message.trimEnd()) +} + +/** + * Validates the integrity of all flag descriptors in [manifests] against Kotlin literal grammars + * before passing them to the code generator. + * + * Threat model: a malicious build-script author of a project dependency declared via + * `featuredAggregation(project(":evil"))` controls the contents of `featured-manifest.json` + * and can supply arbitrary strings for `enumTypeFqn` and `defaultValue`. These fields are + * interpolated verbatim into the generated `.kt` file, so injecting `;`, `{`, `(`, or similar + * characters produces syntactically valid Kotlin with arbitrary code that executes during the + * consuming project's `:compileKotlin`. + * + * We validate against Kotlin grammar here — single source of truth in the task — so the + * generator can never emit unintended syntax regardless of what arrives in the manifest. + * + * @throws IllegalArgumentException when any flag has an invalid [defaultValue] (or, for ENUM, + * an invalid [enumTypeFqn]), naming the offending key and module in the message. + */ +internal fun validateFlagDescriptorIntegrity(manifests: List) { + manifests.forEach { manifest -> + manifest.flags.forEach { flag -> + when (flag.valueType) { + ValueType.BOOLEAN -> { + require(BOOLEAN_LITERAL_REGEX.matches(flag.defaultValue)) { + "Invalid Boolean defaultValue '${flag.defaultValue}' for flag '${flag.key}' " + + "in module '${manifest.modulePath}': must be 'true' or 'false'." + } + } + + ValueType.INT -> { + require(INT_LITERAL_REGEX.matches(flag.defaultValue)) { + "Invalid Int defaultValue '${flag.defaultValue}' for flag '${flag.key}' " + + "in module '${manifest.modulePath}': must be an integer literal (digits, optional leading minus)." + } + } + + ValueType.LONG -> { + require(LONG_LITERAL_REGEX.matches(flag.defaultValue)) { + "Invalid Long defaultValue '${flag.defaultValue}' for flag '${flag.key}' " + + "in module '${manifest.modulePath}': must be an integer literal (digits, optional leading minus)." + } + } + + ValueType.FLOAT -> { + require(FLOAT_LITERAL_REGEX.matches(flag.defaultValue)) { + "Invalid Float defaultValue '${flag.defaultValue}' for flag '${flag.key}' " + + "in module '${manifest.modulePath}': must be a numeric literal (digits, optional decimal and exponent)." + } + } + + ValueType.DOUBLE -> { + require(DOUBLE_LITERAL_REGEX.matches(flag.defaultValue)) { + "Invalid Double defaultValue '${flag.defaultValue}' for flag '${flag.key}' " + + "in module '${manifest.modulePath}': must be a numeric literal (digits, optional decimal and exponent)." + } + } + + ValueType.STRING -> { + // STRING values are escaped via escapeKotlinString in the generator — no validation needed. + } + + ValueType.ENUM -> { + requireNotNull(flag.enumTypeFqn) { + "enumTypeFqn must not be null for ENUM flag '${flag.key}' in module '${manifest.modulePath}'." + } + require(KOTLIN_FQN_REGEX.matches(flag.enumTypeFqn)) { + "Invalid enumTypeFqn '${flag.enumTypeFqn}' for flag '${flag.key}' in module '${manifest.modulePath}': " + + "must be a valid Kotlin fully-qualified name." + } + require(KOTLIN_IDENTIFIER_REGEX.matches(flag.defaultValue)) { + "Invalid ENUM defaultValue '${flag.defaultValue}' for flag '${flag.key}' in module '${manifest.modulePath}': " + + "must be a valid Kotlin identifier." + } + } + } + } + } +} diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGenerator.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGenerator.kt new file mode 100644 index 0000000..554ebe9 --- /dev/null +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGenerator.kt @@ -0,0 +1,167 @@ +package dev.androidbroadcast.featured.gradle.aggregation + +import dev.androidbroadcast.featured.gradle.manifest.FeaturedManifest +import dev.androidbroadcast.featured.gradle.manifest.FlagDescriptor +import dev.androidbroadcast.featured.gradle.manifest.ValueType + +/** + * Generates `GeneratedFeaturedRegistry.kt` source from the aggregated [FeaturedManifest] list. + * + * KMP-safe: imports only `dev.androidbroadcast.featured.ConfigParam`. Enum types are referenced + * inline by their fully-qualified name in both the `ConfigParam<...>` type argument and the + * `defaultValue = ...` expression — no separate enum imports are emitted. + * + * Output determinism: descriptors are sorted by `(modulePath, key)` over the flattened + * list before generation, so the output is identical regardless of the order in which + * Gradle resolves the manifest artifacts. + */ +internal object GeneratedFeaturedRegistryGenerator { + private const val CONFIG_PARAM_IMPORT = "dev.androidbroadcast.featured.ConfigParam" + + /** + * Generates the full `GeneratedFeaturedRegistry.kt` source text. + * + * @param manifests Aggregated manifests from all producer modules. + * @param packageName Package declared at the top of the generated file. + * @return Complete Kotlin source as a single [String]. + */ + fun generate( + manifests: List, + packageName: String, + ): String { + val sorted = + manifests + .flatMap { manifest -> manifest.flags.map { flag -> manifest.modulePath to flag } } + .sortedWith(compareBy({ it.first }, { it.second.key })) + + return buildString { + appendLine("// Auto-generated by Featured Gradle Plugin — do not edit manually.") + appendLine("package $packageName") + appendLine() + appendLine("import $CONFIG_PARAM_IMPORT") + appendLine() + appendLine("public object $FEATURED_REGISTRY_OBJECT {") + if (sorted.isEmpty()) { + appendLine(" public val all: List> = emptyList()") + } else { + appendLine(" public val all: List> = listOf(") + sorted.forEach { (modulePath, descriptor) -> + if (descriptor.valueType == ValueType.ENUM) { + requireNotNull(descriptor.enumTypeFqn) { + "enumTypeFqn must be non-null for ENUM flag '${descriptor.key}' in module '$modulePath'" + } + } + val typeArg = descriptor.valueType.toKotlinTypeName(descriptor.enumTypeFqn) + val defaultLiteral = descriptor.toDefaultLiteral() + val args = + buildList { + add("key = \"${escapeKotlinString(descriptor.key)}\"") + add("defaultValue = $defaultLiteral") + if (descriptor.description != null) add("description = \"${escapeKotlinString(descriptor.description)}\"") + if (descriptor.category != null) add("category = \"${escapeKotlinString(descriptor.category)}\"") + if (descriptor.valueType == ValueType.ENUM) { + add("enumConstants = kotlin.enums.enumEntries<${descriptor.enumTypeFqn}>()") + } + } + // Kotlin accepts trailing commas in listOf() — always emit one for uniform diffs. + appendLine(" ConfigParam<$typeArg>(${args.joinToString(", ")}),") + } + appendLine(" )") + } + append("}") + } + } +} + +/** + * Maps this [ValueType] to the Kotlin type name used in the `ConfigParam` type argument. + * + * For [ValueType.ENUM], [enumTypeFqn] must be non-null; it is used as the full type reference. + */ +private fun ValueType.toKotlinTypeName(enumTypeFqn: String?): String = + when (this) { + ValueType.BOOLEAN -> { + "Boolean" + } + + ValueType.INT -> { + "Int" + } + + ValueType.LONG -> { + "Long" + } + + ValueType.FLOAT -> { + "Float" + } + + ValueType.DOUBLE -> { + "Double" + } + + ValueType.STRING -> { + "String" + } + + ValueType.ENUM -> { + requireNotNull(enumTypeFqn) { + "enumTypeFqn must be non-null for ValueType.ENUM" + } + } + } + +/** + * Escapes a bare string value so it is safe to embed inside a Kotlin double-quoted string literal. + * + * Escape order matters: `\` must be processed first to avoid double-escaping characters + * introduced by subsequent replacements. + * + * - `\` → `\\` (backslash) + * - `"` → `\"` (double-quote) + * - `$` → `${'$'}` (prevents Kotlin string-template interpolation in the generated source) + * - `\n` → `\\n` (newline) + * - `\r` → `\\r` (carriage return) + * - `\t` → `\\t` (tab) + */ +private fun escapeKotlinString(value: String): String = + value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("$", "\${'\$'}") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + +/** + * Produces the Kotlin literal for `defaultValue = ...` in the generated `ConfigParam` call. + * + * STRING: producer stores bare value (surrounding quotes already stripped); re-wrap and + * escape via [escapeKotlinString]. + * LONG: append `L` suffix. + * FLOAT: append `f` suffix. + * ENUM: rebuild as `enumTypeFqn.CONSTANT_NAME`. + * BOOLEAN, INT, DOUBLE: emit raw. + */ +private fun FlagDescriptor.toDefaultLiteral(): String = + when (valueType) { + ValueType.STRING -> { + "\"${escapeKotlinString(defaultValue)}\"" + } + + ValueType.LONG -> { + "${defaultValue}L" + } + + ValueType.FLOAT -> { + "${defaultValue}f" + } + + ValueType.ENUM -> { + "$enumTypeFqn.$defaultValue" + } + + ValueType.BOOLEAN, ValueType.INT, ValueType.DOUBLE -> { + defaultValue + } + } diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifest.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifest.kt new file mode 100644 index 0000000..3af02c9 --- /dev/null +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifest.kt @@ -0,0 +1,145 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * Root manifest published by each module that applies the Featured Gradle plugin. + * + * **Public contract is the JSON wire format documented below.** These Kotlin types are an + * internal producer-side helper. The consumer (PR B aggregator) may implement its own + * deserialization model independently — renaming internal Kotlin fields does NOT break + * the contract; changing the JSON wire format (field names, field semantics, enum variant + * names) DOES break it and requires a [SCHEMA_VERSION] bump. + * + * --- + * + * ## Example JSON + * + * ```json + * { + * "schemaVersion": 1, + * "modulePath": ":feature:checkout", + * "flags": [ + * { + * "key": "dark_mode", + * "propertyName": "darkMode", + * "kind": "LOCAL", + * "valueType": "BOOLEAN", + * "defaultValue": "false" + * }, + * { + * "key": "promo_banner", + * "propertyName": "promoBanner", + * "kind": "REMOTE", + * "valueType": "STRING", + * "defaultValue": "hello world", + * "description": "Show promo banner" + * }, + * { + * "key": "checkout_variant", + * "propertyName": "checkoutVariant", + * "kind": "LOCAL", + * "valueType": "ENUM", + * "defaultValue": "LEGACY", + * "enumTypeFqn": "com.example.CheckoutVariant" + * } + * ] + * } + * ``` + * + * --- + * + * ## Field semantics + * + * - **`modulePath`** — Gradle `Project.path` in the `:foo:bar` format. Root project is `":"`. + * - **`propertyName`** — camelCase Kotlin property name for the aggregator to generate. + * Derived from `key` via `toCamelCase()` in the producer. + * - **`kind`** — `LOCAL` for flags declared in `localFlags { }`, `REMOTE` for `remoteFlags { }`. + * - **`defaultValue`** — raw default value as a string. For `STRING` valueType, the enclosing + * quotes (added by `FlagContainer.string()`) are removed by the producer; the stored value + * is the bare string (e.g. `hello world`, not `"hello world"`). For `ENUM` valueType, only + * the constant name is stored (e.g. `LEGACY`, not `EnumClass.LEGACY`) so that the aggregator + * can pass it directly to `enumValueOf(defaultValue)`. + * - **`enumTypeFqn`** — fully-qualified name of the enum class when `valueType` is `ENUM`; + * `null` for all other types. + * - **`description`**, **`category`**, **`expiresAt`** — optional metadata passed through from + * the DSL. Absent from JSON when null (`explicitNulls = false`). + * + * --- + * + * ## Evolvability policy + * + * | Change | Action | + * |---------------------------------------------|----------------------------------------------| + * | Add optional field with a default | Additive — no schema bump | + * | Remove or rename existing field | Breaking — bump [SCHEMA_VERSION] + `schema-major` attribute | + * | Add new enum variant in [FlagKind]/[ValueType] | Breaking — bump major | + * | Change semantics of existing field | Breaking — bump major | + * + * --- + * + * ## ABI status + * + * The `featured-manifest` Usage attribute and the `schema-major` Gradle attribute are stable + * consumer-facing contracts. See [FeaturedManifestContract] for the attribute constants. + */ +@Serializable +internal data class FeaturedManifest( + val schemaVersion: Int, + val modulePath: String, + // No default value — guarantees that an empty list is serialized as "flags":[] + // rather than being omitted when encodeDefaults = false. + val flags: List, +) + +/** + * Describes a single feature flag declared via the `featured { }` DSL. + * + * Null optional fields are omitted from the JSON output (`explicitNulls = false` in + * [FeaturedManifestJson]). + */ +@Serializable +internal data class FlagDescriptor( + val key: String, + val propertyName: String, + val kind: FlagKind, + val valueType: ValueType, + val defaultValue: String, + val enumTypeFqn: String? = null, + val description: String? = null, + val category: String? = null, + val expiresAt: String? = null, +) + +/** Whether the flag is declared in `localFlags { }` or `remoteFlags { }`. */ +@Serializable +internal enum class FlagKind { LOCAL, REMOTE } + +/** The Kotlin type of the flag's value. */ +@Serializable +internal enum class ValueType { BOOLEAN, INT, LONG, FLOAT, DOUBLE, STRING, ENUM } + +/** Wire-format schema version. Bump this (and the `schema-major` Gradle attribute) on breaking changes. */ +internal const val SCHEMA_VERSION = 1 + +/** + * Pre-configured [Json] instance used for both encoding and decoding [FeaturedManifest]. + * + * - `prettyPrint = true` — human-readable output for easier debugging and diff review. + * - `explicitNulls = false` — null optional fields are omitted from the JSON, keeping + * the output compact and forward-compatible. + * - `encodeDefaults = false` — Kotlin default values are not written if they match the + * declared default. Note: [FeaturedManifest.flags] intentionally has **no** default so + * it is always serialized, even when empty. + * - `ignoreUnknownKeys = true` — forward-compatible decoding: a consumer built against + * schema v1 can safely read a manifest produced by a future schema version that added + * optional fields. + */ +internal val FeaturedManifestJson = + Json { + prettyPrint = true + explicitNulls = false + encodeDefaults = false + ignoreUnknownKeys = true + } diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestContract.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestContract.kt new file mode 100644 index 0000000..f4a2409 --- /dev/null +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestContract.kt @@ -0,0 +1,23 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import org.gradle.api.attributes.Attribute + +internal const val GENERATE_FEATURED_MANIFEST_TASK_NAME = "generateFeaturedManifest" +internal const val FEATURED_MANIFEST_CONFIGURATION_NAME = "featuredManifest" +internal const val FEATURED_MANIFEST_USAGE = "featured-manifest" +internal const val SCHEMA_MAJOR_ATTRIBUTE_NAME = "dev.androidbroadcast.featured.schema-major" + +/** + * Gradle attribute that carries the major version of the Featured manifest schema. + * + * The attribute is declared as `Attribute` for ergonomic use from Kotlin call sites + * (`attribute(schemaMajorAttr, SCHEMA_VERSION)`). Under the hood Kotlin maps `Int` in a + * generic position to `java.lang.Integer`, which is the JVM boxed type Gradle uses for + * attribute equality. [Int.javaObjectType] (`Int::class.javaObjectType`) returns exactly + * `Integer.class`, so this is wire-compatible with a Java-side `Attribute`. + * + * The consumer (PR B aggregator) must declare the same [Attribute] instance — sharing + * this constant guarantees a single Attribute object. + */ +internal val schemaMajorAttr: Attribute = + Attribute.of(SCHEMA_MAJOR_ATTRIBUTE_NAME, Int::class.javaObjectType) diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTask.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTask.kt new file mode 100644 index 0000000..e08b3e5 --- /dev/null +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTask.kt @@ -0,0 +1,140 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import dev.androidbroadcast.featured.gradle.LocalFlagEntry +import dev.androidbroadcast.featured.gradle.parseLocalFlagEntries +import kotlinx.serialization.encodeToString +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +/** + * Generates the per-module `featured-manifest.json` artifact consumed by the PR-B aggregator. + * + * Reads the flag report from [flagsFile] (produced by `resolveFeatureFlags`), maps each + * [LocalFlagEntry] to a [FlagDescriptor], and writes the result as a JSON document to + * [outputFile]. + * + * The output file is published via the `featuredManifest` consumable Gradle configuration + * so that downstream aggregator modules can resolve all per-module manifests through + * normal dependency resolution. + */ +@CacheableTask +internal abstract class GenerateFeaturedManifestTask : DefaultTask() { + /** + * The pipe-delimited flag report produced by `resolveFeatureFlags`. + * + * [PathSensitivity.NONE] is used because this is a generated intermediate file whose + * absolute path varies across machines and build directories. Only the file content + * matters for cache key computation — the path itself is irrelevant to correctness. + * This matches the sensitivity used by all other Generate* tasks that consume the same + * flags.txt file. + */ + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val flagsFile: RegularFileProperty + + /** + * Gradle `Project.path` for this module (e.g. `":feature:checkout"`, `":"`). + * + * Set as a snapshot string at configuration time (not as a lazy provider) to ensure + * Configuration Cache compliance — `Project` instances must not be captured by task + * state at execution time. + */ + @get:Input + abstract val modulePath: Property + + /** + * Output path for the generated `featured-manifest.json`. + * + * The convention `build/featured/featured-manifest.json` is wired by [FeaturedPlugin]; + * it keeps all Featured build outputs under a single directory alongside `flags.txt` + * and `proguard-featured.pro`. + */ + @get:OutputFile + abstract val outputFile: RegularFileProperty + + @TaskAction + fun generate() { + val path = modulePath.get() + require(path.startsWith(":")) { + "modulePath must be a Gradle path starting with ':', was '$path'" + } + + val entries = flagsFile.parseLocalFlagEntries() + val descriptors = entries.map { entry -> entry.toFlagDescriptor() } + val manifest = + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = path, + flags = descriptors, + ) + + val outFile = outputFile.get().asFile + outFile.parentFile?.mkdirs() + outFile.writeText(FeaturedManifestJson.encodeToString(manifest)) + + logger.lifecycle( + "[featured] Generated manifest with ${descriptors.size} flag(s) → ${outFile.path}", + ) + } +} + +internal fun LocalFlagEntry.toFlagDescriptor(): FlagDescriptor { + val kind = if (isLocal) FlagKind.LOCAL else FlagKind.REMOTE + + val valueType = + if (isEnum) { + ValueType.ENUM + } else { + when (type) { + "Boolean" -> ValueType.BOOLEAN + + "Int" -> ValueType.INT + + "Long" -> ValueType.LONG + + "Float" -> ValueType.FLOAT + + "Double" -> ValueType.DOUBLE + + "String" -> ValueType.STRING + + // Explicit error with key name — ValueType.valueOf(type.uppercase()) would produce + // a cryptic "No enum constant" message with no context about which flag failed. + else -> error("Unsupported flag value type '$type' for key '$key'") + } + } + + val resolvedDefault = + when (valueType) { + // FlagContainer.string() wraps the default in escaped quotes: defaultValue = "\"hello\"". + // ScanResultParser stores it verbatim. Strip the surrounding quotes here so the aggregator + // can use the bare value without further processing. + ValueType.STRING -> defaultValue.removeSurrounding("\"") + + // ConfigParamGenerator writes qualified form "EnumType.VARIANT"; only the constant + // name is useful for the aggregator (it calls enumValueOf(defaultValue)). + ValueType.ENUM -> defaultValue.substringAfterLast('.') + + else -> defaultValue + } + + return FlagDescriptor( + key = key, + propertyName = propertyName, + kind = kind, + valueType = valueType, + defaultValue = resolvedDefault, + enumTypeFqn = type.takeIf { isEnum }, + description = description, + category = category, + expiresAt = expiresAt, + ) +} diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/build.gradle.kts new file mode 100644 index 0000000..33c9d94 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + id("com.android.application") version "9.1.0" + id("dev.androidbroadcast.featured") + id("dev.androidbroadcast.featured.application") +} + +android { + namespace = "com.example.testapp" + compileSdk = 36 + defaultConfig { minSdk = 24 } +} + +dependencies { + featuredAggregation(project(":feature-checkout")) + featuredAggregation(project(":feature-profile")) +} diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/src/main/AndroidManifest.xml b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/app/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/build.gradle.kts new file mode 100644 index 0000000..b1af0dc --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/build.gradle.kts @@ -0,0 +1 @@ +// Root build file — no plugins applied at root level. diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/build.gradle.kts new file mode 100644 index 0000000..27628c1 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id("com.android.library") version "9.1.0" + id("dev.androidbroadcast.featured") +} + +android { + namespace = "com.example.featurecheckout" + compileSdk = 36 + defaultConfig { minSdk = 24 } +} + +featured { + localFlags { + boolean("dark_mode", default = false) { category = "UI" } + enum("checkout_variant", typeFqn = "com.example.CheckoutVariant", default = "LEGACY") + } +} diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/src/main/AndroidManifest.xml b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-checkout/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/build.gradle.kts new file mode 100644 index 0000000..326b95e --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id("com.android.library") version "9.1.0" + id("dev.androidbroadcast.featured") +} + +android { + namespace = "com.example.featureprofile" + compileSdk = 36 + defaultConfig { minSdk = 24 } +} + +featured { + localFlags { + string("avatar_placeholder", default = "default.png") + } + remoteFlags { + boolean("show_avatar", default = true) + } +} diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/src/main/AndroidManifest.xml b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/feature-profile/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/gradle.properties b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/gradle.properties new file mode 100644 index 0000000..d621155 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/gradle.properties @@ -0,0 +1,3 @@ +android.useAndroidX=true +org.gradle.configuration-cache=true +org.gradle.unsafe.configuration-cache-problems=warn diff --git a/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/settings.gradle.kts b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/settings.gradle.kts new file mode 100644 index 0000000..14a18d1 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/aggregator-multi-module-project/settings.gradle.kts @@ -0,0 +1,33 @@ +// AGP and the Featured plugin are injected via GradleRunner.withPluginClasspath(). +pluginManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + @Suppress("UnstableApiUsage") + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + } +} + +rootProject.name = "aggregator-multi-module-project" +include(":feature-checkout") +include(":feature-profile") +include(":app") diff --git a/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/build.gradle.kts new file mode 100644 index 0000000..6147eee --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + id("java-library") + id("dev.androidbroadcast.featured") +} + +// No featured { } block — the plugin is applied with zero flag declarations. +// Expected: generateFeaturedManifest produces a manifest with an empty flags array. diff --git a/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/settings.gradle.kts b/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/settings.gradle.kts new file mode 100644 index 0000000..d0f39ff --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/settings.gradle.kts @@ -0,0 +1,9 @@ +// The Featured plugin is injected via GradleRunner.withPluginClasspath(). +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +rootProject.name = "jvm-empty-featured-project" diff --git a/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/build.gradle.kts new file mode 100644 index 0000000..b1af0dc --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/build.gradle.kts @@ -0,0 +1 @@ +// Root build file — no plugins applied at root level. diff --git a/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/gradle.properties b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/gradle.properties new file mode 100644 index 0000000..5ad6974 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/gradle.properties @@ -0,0 +1 @@ +org.gradle.configuration-cache=true diff --git a/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/build.gradle.kts new file mode 100644 index 0000000..ccf89fb --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/build.gradle.kts @@ -0,0 +1,31 @@ +plugins { + id("org.jetbrains.kotlin.multiplatform") version "2.3.10" + id("dev.androidbroadcast.featured") + id("maven-publish") +} + +kotlin { + jvm() + + sourceSets { + commonMain {} + } +} + +group = "com.example.test" +version = "0.1.0" + +featured { + localFlags { + boolean("debug_overlay", default = false) + } +} + +publishing { + repositories { + maven { + name = "TestLocal" + url = uri(layout.buildDirectory.dir("test-repo")) + } + } +} diff --git a/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/src/commonMain/kotlin/.gitkeep b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/src/commonMain/kotlin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/settings.gradle.kts b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/settings.gradle.kts new file mode 100644 index 0000000..ac37701 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/settings.gradle.kts @@ -0,0 +1,17 @@ +// The Featured plugin and Kotlin Multiplatform plugin are injected via GradleRunner.withPluginClasspath(). +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + @Suppress("UnstableApiUsage") + repositories { + mavenCentral() + } +} + +rootProject.name = "kmp-publish-project" +include(":module") diff --git a/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/build.gradle.kts new file mode 100644 index 0000000..1b46cb2 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + id("com.android.library") version "9.1.0" + id("dev.androidbroadcast.featured") +} + +android { + namespace = "com.example.testapp" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + } +} + +featured { + localFlags { + boolean("dark_mode", default = false) { + category = "UI" + } + enum("checkout_variant", typeFqn = "com.example.CheckoutVariant", default = "LEGACY") + } + remoteFlags { + boolean("promo_banner", default = false) { + description = "Show promo banner" + } + } +} diff --git a/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/src/main/AndroidManifest.xml b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..94cbbcf --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/build.gradle.kts new file mode 100644 index 0000000..b1af0dc --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/build.gradle.kts @@ -0,0 +1 @@ +// Root build file — no plugins applied at root level. diff --git a/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/gradle.properties b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/gradle.properties new file mode 100644 index 0000000..d621155 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/gradle.properties @@ -0,0 +1,3 @@ +android.useAndroidX=true +org.gradle.configuration-cache=true +org.gradle.unsafe.configuration-cache-problems=warn diff --git a/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/settings.gradle.kts b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/settings.gradle.kts new file mode 100644 index 0000000..ae64af0 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/settings.gradle.kts @@ -0,0 +1,31 @@ +// AGP and the Featured plugin are injected via GradleRunner.withPluginClasspath(). +pluginManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + @Suppress("UnstableApiUsage") + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + } +} + +rootProject.name = "manifest-publish-project" +include(":app") diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGeneratorTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGeneratorTest.kt index 1ea6cb5..399e258 100644 --- a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGeneratorTest.kt +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ConfigParamGeneratorTest.kt @@ -6,20 +6,22 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue class ConfigParamGeneratorTest { + private val modulePath = ":app" + // ── local flags ─────────────────────────────────────────────────────────── @Test - fun `generates GeneratedLocalFlags object for local boolean flag`() { + fun `generates module-suffixed local flags object for local boolean flag`() { val entries = listOf(localEntry("dark_mode", "false", "Boolean")) - val (local, _) = ConfigParamGenerator.generate(entries) - assertContains(local, "object GeneratedLocalFlags") + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) + assertContains(local, "object GeneratedLocalFlagsApp") assertContains(local, "val darkMode = ConfigParam") } @Test fun `generated local ConfigParam uses named key argument`() { val entries = listOf(localEntry("dark_mode", "false", "Boolean")) - val (local, _) = ConfigParamGenerator.generate(entries) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) assertContains(local, "key = \"dark_mode\"") assertContains(local, "defaultValue = false") } @@ -27,67 +29,81 @@ class ConfigParamGeneratorTest { @Test fun `generated local ConfigParam includes description when present`() { val entry = localEntry("dark_mode", "false", "Boolean").copy(description = "Enable dark mode") - val (local, _) = ConfigParamGenerator.generate(listOf(entry)) + val (local, _) = ConfigParamGenerator.generate(listOf(entry), modulePath) assertContains(local, "description = \"Enable dark mode\"") } @Test fun `generated local ConfigParam includes category when present`() { val entry = localEntry("dark_mode", "false", "Boolean").copy(category = "UI") - val (local, _) = ConfigParamGenerator.generate(listOf(entry)) + val (local, _) = ConfigParamGenerator.generate(listOf(entry), modulePath) assertContains(local, "category = \"UI\"") } @Test fun `generated local ConfigParam omits null description`() { val entries = listOf(localEntry("dark_mode", "false", "Boolean")) - val (local, _) = ConfigParamGenerator.generate(entries) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) assertTrue(!local.contains("description ="), "Null description must not appear in output") } @Test fun `local object is internal`() { val entries = listOf(localEntry("dark_mode", "false", "Boolean")) - val (local, _) = ConfigParamGenerator.generate(entries) - assertContains(local, "internal object GeneratedLocalFlags") + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) + assertContains(local, "internal object GeneratedLocalFlagsApp") + } + + @Test + fun `local properties do not have explicit public modifier`() { + val entries = listOf(localEntry("dark_mode", "false", "Boolean")) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) + assertTrue(!local.contains("public val "), "Property declarations must not carry explicit 'public' modifier") } @Test fun `formats Long default with L suffix`() { val entries = listOf(localEntry("timeout", "5000L", "Long")) - val (local, _) = ConfigParamGenerator.generate(entries) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) assertContains(local, "defaultValue = 5000L") } @Test fun `formats Float default with f suffix`() { val entries = listOf(localEntry("ratio", "0.5f", "Float")) - val (local, _) = ConfigParamGenerator.generate(entries) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) assertContains(local, "defaultValue = 0.5f") } @Test fun `passes String default as-is (already quoted)`() { val entries = listOf(localEntry("url", "\"https://x.com\"", "String")) - val (local, _) = ConfigParamGenerator.generate(entries) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) assertContains(local, "defaultValue = \"https://x.com\"") } // ── remote flags ────────────────────────────────────────────────────────── @Test - fun `generates GeneratedRemoteFlags object for remote flag`() { + fun `generates module-suffixed remote flags object for remote flag`() { val entries = listOf(remoteEntry("promo_banner", "false", "Boolean")) - val (_, remote) = ConfigParamGenerator.generate(entries) - assertContains(remote, "object GeneratedRemoteFlags") + val (_, remote) = ConfigParamGenerator.generate(entries, modulePath) + assertContains(remote, "object GeneratedRemoteFlagsApp") assertContains(remote, "val promoBanner = ConfigParam") } @Test fun `remote object is internal`() { val entries = listOf(remoteEntry("promo", "false", "Boolean")) - val (_, remote) = ConfigParamGenerator.generate(entries) - assertContains(remote, "internal object GeneratedRemoteFlags") + val (_, remote) = ConfigParamGenerator.generate(entries, modulePath) + assertContains(remote, "internal object GeneratedRemoteFlagsApp") + } + + @Test + fun `remote properties do not have explicit public modifier`() { + val entries = listOf(remoteEntry("promo", "false", "Boolean")) + val (_, remote) = ConfigParamGenerator.generate(entries, modulePath) + assertTrue(!remote.contains("public val "), "Property declarations must not carry explicit 'public' modifier") } // ── empty cases ─────────────────────────────────────────────────────────── @@ -95,20 +111,20 @@ class ConfigParamGeneratorTest { @Test fun `returns empty string for local when no local flags`() { val entries = listOf(remoteEntry("promo", "false", "Boolean")) - val (local, _) = ConfigParamGenerator.generate(entries) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) assertTrue(local.isEmpty(), "Expected empty local source when no local flags") } @Test fun `returns empty string for remote when no remote flags`() { val entries = listOf(localEntry("dark_mode", "false", "Boolean")) - val (_, remote) = ConfigParamGenerator.generate(entries) + val (_, remote) = ConfigParamGenerator.generate(entries, modulePath) assertTrue(remote.isEmpty(), "Expected empty remote source when no remote flags") } @Test fun `both empty for empty entries list`() { - val (local, remote) = ConfigParamGenerator.generate(emptyList()) + val (local, remote) = ConfigParamGenerator.generate(emptyList(), modulePath) assertEquals("", local) assertEquals("", remote) } @@ -118,40 +134,82 @@ class ConfigParamGeneratorTest { @Test fun `generated local file imports ConfigParam`() { val entries = listOf(localEntry("flag", "false", "Boolean")) - val (local, _) = ConfigParamGenerator.generate(entries) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) assertContains(local, "import dev.androidbroadcast.featured.ConfigParam") } @Test fun `generated file has auto-generated comment`() { val entries = listOf(localEntry("flag", "false", "Boolean")) - val (local, _) = ConfigParamGenerator.generate(entries) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) assertContains(local, "Auto-generated by Featured Gradle Plugin") } + // ── module-derived naming ───────────────────────────────────────────────── + + @Test + fun `different modules produce different object names`() { + val entries = listOf(localEntry("dark_mode", "false", "Boolean")) + val (localA, _) = ConfigParamGenerator.generate(entries, ":feature:checkout") + val (localB, _) = ConfigParamGenerator.generate(entries, ":feature:ui") + assertContains(localA, "object GeneratedLocalFlagsFeatureCheckout") + assertContains(localB, "object GeneratedLocalFlagsFeatureUi") + } + + @Test + fun `hyphenated module segment produces valid object name`() { + val entries = listOf(localEntry("dark_mode", "false", "Boolean")) + val (local, _) = ConfigParamGenerator.generate(entries, ":sample:feature-checkout") + assertContains(local, "object GeneratedLocalFlagsSampleFeatureCheckout") + } + + @Test + fun `localFileName uses module suffix`() { + assertEquals("GeneratedLocalFlagsSampleFeatureCheckout.kt", ConfigParamGenerator.localFileName(":sample:feature-checkout")) + } + + @Test + fun `remoteFileName uses module suffix`() { + assertEquals("GeneratedRemoteFlagsSampleFeaturePromotions.kt", ConfigParamGenerator.remoteFileName(":sample:feature-promotions")) + } + // ── enum flags ──────────────────────────────────────────────────────────── @Test fun `generates enum ConfigParam with fqn type argument`() { val entries = listOf(localEntry("checkout_variant", "LEGACY", "com.example.CheckoutVariant")) - val (local, _) = ConfigParamGenerator.generate(entries) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) assertContains(local, "ConfigParam") } @Test fun `enum default value uses fqn dot constant syntax`() { val entries = listOf(localEntry("checkout_variant", "LEGACY", "com.example.CheckoutVariant")) - val (local, _) = ConfigParamGenerator.generate(entries) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) assertContains(local, "defaultValue = com.example.CheckoutVariant.LEGACY") } @Test fun `enum flag is included in local object`() { val entries = listOf(localEntry("checkout_variant", "LEGACY", "com.example.CheckoutVariant")) - val (local, _) = ConfigParamGenerator.generate(entries) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) assertContains(local, "val checkoutVariant = ConfigParam") } + @Test + fun `enum flag emits enumConstants with kotlin enumEntries call`() { + val entries = listOf(localEntry("checkout_variant", "LEGACY", "com.example.CheckoutVariant")) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) + assertContains(local, "enumConstants = kotlin.enums.enumEntries()") + } + + @Test + fun `non-enum flag does not emit enumConstants`() { + val entries = listOf(localEntry("dark_mode", "false", "Boolean")) + val (local, _) = ConfigParamGenerator.generate(entries, modulePath) + assertTrue(!local.contains("enumConstants ="), "Non-enum flag must not emit enumConstants") + } + // ── helpers ─────────────────────────────────────────────────────────────── private fun localEntry( @@ -162,7 +220,7 @@ class ConfigParamGeneratorTest { key = key, defaultValue = default, type = type, - moduleName = ":app", + moduleName = modulePath, propertyName = key.toCamelCase(), flagType = LocalFlagEntry.FLAG_TYPE_LOCAL, ) @@ -175,7 +233,7 @@ class ConfigParamGeneratorTest { key = key, defaultValue = default, type = type, - moduleName = ":app", + moduleName = modulePath, propertyName = key.toCamelCase(), flagType = LocalFlagEntry.FLAG_TYPE_REMOTE, ) diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGeneratorTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGeneratorTest.kt index ad2e938..5e28f7f 100644 --- a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGeneratorTest.kt +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ExtensionFunctionGeneratorTest.kt @@ -8,26 +8,33 @@ import kotlin.test.assertTrue class ExtensionFunctionGeneratorTest { private val modulePath = ":feature:checkout" - // ── JVM file name ───────────────────────────────────────────────────────── + // ── generated file name ─────────────────────────────────────────────────── @Test - fun `jvmFileName for app module`() { - val name = ExtensionFunctionGenerator.jvmFileName(":app") + fun `fileName for app module`() { + val name = ExtensionFunctionGenerator.fileName(":app") assertContains(name, "App") - assertContains(name, "FlagExtensionsKt") + assertTrue(name.endsWith(".kt"), "File name must end with .kt") } @Test - fun `jvmFileName for nested module`() { - val name = ExtensionFunctionGenerator.jvmFileName(":feature:checkout") + fun `fileName for nested module`() { + val name = ExtensionFunctionGenerator.fileName(":feature:checkout") assertContains(name, "FeatureCheckout") } @Test - fun `jvmFileName for different modules are distinct`() { - val a = ExtensionFunctionGenerator.jvmFileName(":feature:checkout") - val b = ExtensionFunctionGenerator.jvmFileName(":feature:ui") - assertFalse(a == b, "Different modules must produce distinct JVM names") + fun `fileName for hyphenated module segment`() { + val name = ExtensionFunctionGenerator.fileName(":sample:feature-checkout") + assertContains(name, "SampleFeatureCheckout") + assertFalse(name.contains("-"), "File name must not contain hyphens") + } + + @Test + fun `fileName for different modules are distinct`() { + val a = ExtensionFunctionGenerator.fileName(":feature:checkout") + val b = ExtensionFunctionGenerator.fileName(":feature:ui") + assertFalse(a == b, "Different modules must produce distinct file names") } // ── empty input ─────────────────────────────────────────────────────────── @@ -48,17 +55,18 @@ class ExtensionFunctionGeneratorTest { } @Test - fun `local boolean extension returns raw value`() { + fun `local boolean extension returns raw value via getValueCached`() { val entries = listOf(localEntry("dark_mode", "Boolean")) val source = ExtensionFunctionGenerator.generate(entries, modulePath) - assertContains(source, "getValue(GeneratedLocalFlags.darkMode).value") + assertContains(source, "getValueCached(GeneratedLocalFlagsFeatureCheckout.darkMode).value") } @Test - fun `local boolean extension is public`() { + fun `local boolean extension is internal non-suspend`() { val entries = listOf(localEntry("dark_mode", "Boolean")) val source = ExtensionFunctionGenerator.generate(entries, modulePath) - assertContains(source, "public fun ConfigValues.isDarkModeEnabled()") + assertContains(source, "internal fun ConfigValues.isDarkModeEnabled()") + assertFalse(source.contains("suspend fun ConfigValues.isDarkModeEnabled()"), "Must not emit suspend modifier") } // ── local non-boolean flag ──────────────────────────────────────────────── @@ -68,7 +76,7 @@ class ExtensionFunctionGeneratorTest { val entries = listOf(localEntry("max_retries", "Int")) val source = ExtensionFunctionGenerator.generate(entries, modulePath) assertContains(source, "fun ConfigValues.getMaxRetries(): Int") - assertContains(source, "getValue(GeneratedLocalFlags.maxRetries).value") + assertContains(source, "getValueCached(GeneratedLocalFlagsFeatureCheckout.maxRetries).value") } @Test @@ -85,7 +93,7 @@ class ExtensionFunctionGeneratorTest { val entries = listOf(localEntry("checkout_variant", "com.example.CheckoutVariant")) val source = ExtensionFunctionGenerator.generate(entries, modulePath) assertContains(source, "fun ConfigValues.getCheckoutVariant(): com.example.CheckoutVariant") - assertContains(source, "getValue(GeneratedLocalFlags.checkoutVariant).value") + assertContains(source, "getValueCached(GeneratedLocalFlagsFeatureCheckout.checkoutVariant).value") } @Test @@ -102,7 +110,8 @@ class ExtensionFunctionGeneratorTest { val entries = listOf(remoteEntry("promo_banner", "Boolean")) val source = ExtensionFunctionGenerator.generate(entries, modulePath) assertContains(source, "fun ConfigValues.getPromoBanner(): ConfigValue") - assertContains(source, "getValue(GeneratedRemoteFlags.promoBanner)") + assertContains(source, "getValueCached(GeneratedRemoteFlagsFeatureCheckout.promoBanner)") + assertFalse(source.contains("suspend "), "Must not emit suspend modifier anywhere") } @Test @@ -110,7 +119,7 @@ class ExtensionFunctionGeneratorTest { val entries = listOf(remoteEntry("promo_banner", "Boolean")) val source = ExtensionFunctionGenerator.generate(entries, modulePath) assertFalse( - source.contains("GeneratedRemoteFlags.promoBanner).value"), + source.contains("GeneratedRemoteFlagsFeatureCheckout.promoBanner).value"), "Remote extensions must return full ConfigValue, not unwrapped value", ) } @@ -118,10 +127,12 @@ class ExtensionFunctionGeneratorTest { // ── file structure ──────────────────────────────────────────────────────── @Test - fun `generated file has JvmName annotation`() { + fun `generated file does not contain JvmName annotation`() { + // @file:JvmName is not supported on Kotlin/Native; class-name uniqueness is + // achieved via the module-derived file name instead. val entries = listOf(localEntry("flag", "Boolean")) val source = ExtensionFunctionGenerator.generate(entries, modulePath) - assertContains(source, "@file:JvmName(\"${ExtensionFunctionGenerator.jvmFileName(modulePath)}\")") + assertFalse(source.contains("@file:JvmName"), "Generated file must not contain @file:JvmName") } @Test diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt index 7088533..b894b48 100644 --- a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt @@ -15,7 +15,7 @@ import kotlin.test.assertTrue * End-to-end integration test that verifies the Featured Gradle plugin: * 1. Generates a ProGuard file at `build/featured/proguard-featured.pro` with correct * `-assumevalues` rules for declared local flags. - * 2. Auto-wires that file into the AGP release variant so the `generateProguardRules` + * 2. Auto-wires that file into the AGP release variant so the `generateFeaturedProguardRules` * task participates in `assembleRelease`. * * The test uses a minimal Android application fixture copied from @@ -49,17 +49,17 @@ class FeaturedPluginIntegrationTest { // ── Tests ───────────────────────────────────────────────────────────────── @Test - fun `generateProguardRules task produces correct assumevalues rule for boolean local flag`() { + fun `generateFeaturedProguardRules task produces correct assumevalues rule for boolean local flag`() { val result = gradleRunner(projectDir) - .withArguments("generateProguardRules", "--stacktrace") + .withArguments("generateFeaturedProguardRules", "--stacktrace") .build() - val outcome = result.task(":generateProguardRules")?.outcome + val outcome = result.task(":generateFeaturedProguardRules")?.outcome assertEquals( TaskOutcome.SUCCESS, outcome, - "Expected :generateProguardRules to succeed, got $outcome\n${result.output}", + "Expected :generateFeaturedProguardRules to succeed, got $outcome\n${result.output}", ) val proFile = projectDir.resolve("build/featured/proguard-featured.pro") @@ -138,13 +138,13 @@ class FeaturedPluginIntegrationTest { .withArguments(args) .build() - // generateProguardRules must have run as part of the release build. - val proguardOutcome = result.task(":generateProguardRules")?.outcome + // generateFeaturedProguardRules must have run as part of the release build. + val proguardOutcome = result.task(":generateFeaturedProguardRules")?.outcome assertTrue( proguardOutcome == TaskOutcome.SUCCESS || proguardOutcome == TaskOutcome.UP_TO_DATE || proguardOutcome == TaskOutcome.FROM_CACHE, - "Expected :generateProguardRules to participate in assembleRelease (cc=$cc), got $proguardOutcome\n${result.output}", + "Expected :generateFeaturedProguardRules to participate in assembleRelease (cc=$cc), got $proguardOutcome\n${result.output}", ) // On the second CC-enabled run, the cache is reused AND all task outputs are unchanged, @@ -189,13 +189,14 @@ class FeaturedPluginIntegrationTest { * * Expected output (from [ProguardRulesGenerator]): * ```proguard - * -assumevalues class dev.androidbroadcast.featured.generated.FeaturedRoot_FlagExtensionsKt { + * -assumevalues class dev.androidbroadcast.featured.generated.GeneratedFlagExtensionsRootKt { * boolean isDarkModeEnabled(dev.androidbroadcast.featured.ConfigValues) return false; * } * ``` * - * The root module path `:` produces the identifier `Root` via [String.modulePathToIdentifier], - * so the JVM class name is `FeaturedRoot_FlagExtensionsKt`. + * The root module path `:` produces the file suffix `Root` via [String.modulePathToFileSuffix], + * so the Kotlin file is `GeneratedFlagExtensionsRoot.kt` and the JVM class name + * (Kotlin's file-to-class convention) is `GeneratedFlagExtensionsRootKt`. * * Enum flags (`checkout_variant`) must not appear in `-assumevalues` rules — their values * are resolved at runtime from providers and cannot be assumed at build time (issue #162). @@ -281,9 +282,10 @@ class FeaturedPluginIntegrationTest { private companion object { // The fixture is a single-project (root) build. - // modulePathToIdentifier(":") → "Root" → jvmFileName → "FeaturedRoot_FlagExtensionsKt" + // modulePathToFileSuffix(":") → "Root" → fileName → "GeneratedFlagExtensionsRoot.kt" + // → JVM class: "GeneratedFlagExtensionsRootKt" const val EXTENSIONS_FQN = - "dev.androidbroadcast.featured.generated.FeaturedRoot_FlagExtensionsKt" + "dev.androidbroadcast.featured.generated.GeneratedFlagExtensionsRootKt" const val CONFIG_VALUES_FQN = "dev.androidbroadcast.featured.ConfigValues" const val IS_DARK_MODE_ENABLED = "isDarkModeEnabled" } diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtilsTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtilsTest.kt index 91f41eb..ff8625f 100644 --- a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtilsTest.kt +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagEntryUtilsTest.kt @@ -62,4 +62,36 @@ class FlagEntryUtilsTest { fun `modulePathToIdentifier bare colon returns Root`() { assertEquals("Root", ":".modulePathToIdentifier()) } + + // ── modulePathToFileSuffix ──────────────────────────────────────────────── + + @Test + fun `modulePathToFileSuffix for root app module`() { + assertEquals("App", ":app".modulePathToFileSuffix()) + } + + @Test + fun `modulePathToFileSuffix for nested module`() { + assertEquals("FeatureCheckout", ":feature:checkout".modulePathToFileSuffix()) + } + + @Test + fun `modulePathToFileSuffix for hyphenated segment`() { + assertEquals("SampleFeatureCheckout", ":sample:feature-checkout".modulePathToFileSuffix()) + } + + @Test + fun `modulePathToFileSuffix for deeply nested module`() { + assertEquals("FeaturePaymentUi", ":feature:payment:ui".modulePathToFileSuffix()) + } + + @Test + fun `modulePathToFileSuffix empty string returns Root`() { + assertEquals("Root", "".modulePathToFileSuffix()) + } + + @Test + fun `modulePathToFileSuffix bare colon returns Root`() { + assertEquals("Root", ":".modulePathToFileSuffix()) + } } diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagRegistrarGeneratorTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagRegistrarGeneratorTest.kt deleted file mode 100644 index 3cab987..0000000 --- a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FlagRegistrarGeneratorTest.kt +++ /dev/null @@ -1,143 +0,0 @@ -package dev.androidbroadcast.featured.gradle - -import kotlin.test.Test -import kotlin.test.assertContains -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class FlagRegistrarGeneratorTest { - @Test - fun `generates package declaration`() { - val source = FlagRegistrarGenerator.generate(emptyList(), packageName = "com.example.generated") - assertContains(source, "package com.example.generated") - } - - @Test - fun `generates FlagRegistry import`() { - val source = FlagRegistrarGenerator.generate(emptyList()) - assertContains(source, "import dev.androidbroadcast.featured.registry.FlagRegistry") - } - - @Test - fun `generates public GeneratedFlagRegistrar object`() { - val source = FlagRegistrarGenerator.generate(emptyList()) - assertContains(source, "public object GeneratedFlagRegistrar") - } - - @Test - fun `generates public register function`() { - val source = FlagRegistrarGenerator.generate(emptyList()) - assertContains(source, "public fun register()") - } - - @Test - fun `generates empty register body when no entries`() { - val source = FlagRegistrarGenerator.generate(emptyList()) - assertFalse(source.contains("FlagRegistry.register("), "No register calls for empty entries") - } - - @Test - fun `generates GeneratedLocalFlags import for local flag`() { - val entries = listOf(localEntry("dark_mode", "darkMode")) - val source = FlagRegistrarGenerator.generate(entries) - assertContains(source, "import dev.androidbroadcast.featured.generated.${LocalFlagEntry.GENERATED_LOCAL_OBJECT}") - } - - @Test - fun `generates GeneratedRemoteFlags import for remote flag`() { - val entries = listOf(remoteEntry("promo_banner", "promoBanner")) - val source = FlagRegistrarGenerator.generate(entries) - assertContains(source, "import dev.androidbroadcast.featured.generated.${LocalFlagEntry.GENERATED_REMOTE_OBJECT}") - } - - @Test - fun `does not generate local import when no local flags`() { - val entries = listOf(remoteEntry("promo", "promo")) - val source = FlagRegistrarGenerator.generate(entries) - assertFalse(source.contains(LocalFlagEntry.GENERATED_LOCAL_OBJECT + "\n")) - } - - @Test - fun `generates register call referencing GeneratedLocalFlags for local flag`() { - val entries = listOf(localEntry("dark_mode", "darkMode")) - val source = FlagRegistrarGenerator.generate(entries) - assertContains(source, "FlagRegistry.register(${LocalFlagEntry.GENERATED_LOCAL_OBJECT}.darkMode)") - } - - @Test - fun `generates register call referencing GeneratedRemoteFlags for remote flag`() { - val entries = listOf(remoteEntry("promo_banner", "promoBanner")) - val source = FlagRegistrarGenerator.generate(entries) - assertContains(source, "FlagRegistry.register(${LocalFlagEntry.GENERATED_REMOTE_OBJECT}.promoBanner)") - } - - @Test - fun `generates register calls for mixed local and remote entries`() { - val entries = - listOf( - localEntry("dark_mode", "darkMode"), - remoteEntry("promo_banner", "promoBanner"), - ) - val source = FlagRegistrarGenerator.generate(entries) - assertContains(source, "FlagRegistry.register(${LocalFlagEntry.GENERATED_LOCAL_OBJECT}.darkMode)") - assertContains(source, "FlagRegistry.register(${LocalFlagEntry.GENERATED_REMOTE_OBJECT}.promoBanner)") - } - - @Test - fun `generates TODO comment for blank property name`() { - val entries = - listOf( - LocalFlagEntry(key = "legacy_flag", defaultValue = "false", type = "Boolean", moduleName = ":app"), - ) - val source = FlagRegistrarGenerator.generate(entries) - assertContains(source, "// TODO: register flag 'legacy_flag'") - assertFalse(source.contains("FlagRegistry.register()")) - } - - @Test - fun `generated source contains auto-generated comment`() { - val source = FlagRegistrarGenerator.generate(emptyList()) - assertContains(source, "Auto-generated by Featured Gradle Plugin") - } - - @Test - fun `uses default package name when not specified`() { - val source = FlagRegistrarGenerator.generate(emptyList()) - assertContains(source, "package dev.androidbroadcast.featured.generated") - } - - @Test - fun `generated source has balanced braces`() { - val entries = listOf(localEntry("flag", "flag")) - val source = FlagRegistrarGenerator.generate(entries) - val open = source.count { it == '{' } - val close = source.count { it == '}' } - assertTrue(open == close && open >= 2, "Expected balanced braces, open=$open close=$close") - } - - // ── helpers ─────────────────────────────────────────────────────────────── - - private fun localEntry( - key: String, - propertyName: String, - ) = LocalFlagEntry( - key = key, - defaultValue = "false", - type = "Boolean", - moduleName = ":app", - propertyName = propertyName, - flagType = LocalFlagEntry.FLAG_TYPE_LOCAL, - ) - - private fun remoteEntry( - key: String, - propertyName: String, - ) = LocalFlagEntry( - key = key, - defaultValue = "false", - type = "Boolean", - moduleName = ":app", - propertyName = propertyName, - flagType = LocalFlagEntry.FLAG_TYPE_REMOTE, - ) -} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateFlagRegistrarTaskRegistrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateFlagRegistrarTaskRegistrationTest.kt deleted file mode 100644 index 4993e8f..0000000 --- a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateFlagRegistrarTaskRegistrationTest.kt +++ /dev/null @@ -1,110 +0,0 @@ -package dev.androidbroadcast.featured.gradle - -import org.gradle.testfixtures.ProjectBuilder -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class GenerateFlagRegistrarTaskRegistrationTest { - @Test - fun `plugin registers generateFlagRegistrar task`() { - val project = ProjectBuilder.builder().build() - project.plugins.apply("dev.androidbroadcast.featured") - - assertNotNull( - project.tasks.findByName(GENERATE_FLAG_REGISTRAR_TASK_NAME), - "Expected '$GENERATE_FLAG_REGISTRAR_TASK_NAME' task to be registered by the plugin", - ) - } - - @Test - fun `generateFlagRegistrar task is of correct type`() { - val project = ProjectBuilder.builder().build() - project.plugins.apply("dev.androidbroadcast.featured") - - val task = project.tasks.findByName(GENERATE_FLAG_REGISTRAR_TASK_NAME) - assertNotNull(task) - assertTrue( - task is GenerateFlagRegistrarTask, - "Expected task type GenerateFlagRegistrarTask but was ${task::class.simpleName}", - ) - } - - @Test - fun `generateFlagRegistrar task is in featured group`() { - val project = ProjectBuilder.builder().build() - project.plugins.apply("dev.androidbroadcast.featured") - - val task = project.tasks.findByName(GENERATE_FLAG_REGISTRAR_TASK_NAME) - assertNotNull(task) - assertEquals( - "featured", - task.group, - "Expected task group 'featured' but was '${task.group}'", - ) - } - - @Test - fun `generateFlagRegistrar task has outputFile configured`() { - val project = ProjectBuilder.builder().build() - project.plugins.apply("dev.androidbroadcast.featured") - - val task = project.tasks.findByName(GENERATE_FLAG_REGISTRAR_TASK_NAME) as? GenerateFlagRegistrarTask - assertNotNull(task) - assertTrue( - task.outputFile.isPresent, - "Expected outputFile to be configured on GenerateFlagRegistrarTask", - ) - } - - @Test - fun `generateFlagRegistrar task has packageName configured`() { - val project = ProjectBuilder.builder().build() - project.plugins.apply("dev.androidbroadcast.featured") - - val task = project.tasks.findByName(GENERATE_FLAG_REGISTRAR_TASK_NAME) as? GenerateFlagRegistrarTask - assertNotNull(task) - assertTrue( - task.packageName.isPresent, - "Expected packageName to be configured on GenerateFlagRegistrarTask", - ) - assertEquals( - "dev.androidbroadcast.featured.generated", - task.packageName.get(), - "Expected default package name 'dev.androidbroadcast.featured.generated'", - ) - } - - @Test - fun `generateFlagRegistrar task depends on resolveFeatureFlags task`() { - val project = ProjectBuilder.builder().build() - project.plugins.apply("dev.androidbroadcast.featured") - - val generateTask = project.tasks.findByName(GENERATE_FLAG_REGISTRAR_TASK_NAME) - assertNotNull(generateTask) - val scanTask = project.tasks.findByName(RESOLVE_FLAGS_TASK_NAME) - assertNotNull(scanTask) - assertTrue( - generateTask.taskDependencies.getDependencies(generateTask).contains(scanTask), - "Expected '$GENERATE_FLAG_REGISTRAR_TASK_NAME' to depend on '$RESOLVE_FLAGS_TASK_NAME'", - ) - } - - @Test - fun `generateFlagRegistrar outputFile is inside build generated featured directory`() { - val project = ProjectBuilder.builder().build() - project.plugins.apply("dev.androidbroadcast.featured") - - val task = project.tasks.findByName(GENERATE_FLAG_REGISTRAR_TASK_NAME) as? GenerateFlagRegistrarTask - assertNotNull(task) - val outputPath = - task.outputFile - .get() - .asFile.path - assertTrue( - outputPath.contains("generated/featured"), - "Expected outputFile inside 'generated/featured' directory, got: $outputPath", - ) - } -} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTaskRegistrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTaskRegistrationTest.kt index 60bc461..0afbe13 100644 --- a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTaskRegistrationTest.kt +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/GenerateProguardRulesTaskRegistrationTest.kt @@ -8,7 +8,7 @@ import kotlin.test.assertTrue class GenerateProguardRulesTaskRegistrationTest { @Test - fun `plugin registers generateProguardRules task`() { + fun `plugin registers generateFeaturedProguardRules task`() { val project = ProjectBuilder.builder().build() project.plugins.apply("dev.androidbroadcast.featured") @@ -19,7 +19,7 @@ class GenerateProguardRulesTaskRegistrationTest { } @Test - fun `generateProguardRules task is of correct type`() { + fun `generateFeaturedProguardRules task is of correct type`() { val project = ProjectBuilder.builder().build() project.plugins.apply("dev.androidbroadcast.featured") @@ -29,7 +29,7 @@ class GenerateProguardRulesTaskRegistrationTest { } @Test - fun `generateProguardRules task is in featured group`() { + fun `generateFeaturedProguardRules task is in featured group`() { val project = ProjectBuilder.builder().build() project.plugins.apply("dev.androidbroadcast.featured") @@ -39,7 +39,7 @@ class GenerateProguardRulesTaskRegistrationTest { } @Test - fun `generateProguardRules task has outputFile configured`() { + fun `generateFeaturedProguardRules task has outputFile configured`() { val project = ProjectBuilder.builder().build() project.plugins.apply("dev.androidbroadcast.featured") @@ -49,7 +49,7 @@ class GenerateProguardRulesTaskRegistrationTest { } @Test - fun `generateProguardRules task depends on resolveFeatureFlags task`() { + fun `generateFeaturedProguardRules task depends on resolveFeatureFlags task`() { val project = ProjectBuilder.builder().build() project.plugins.apply("dev.androidbroadcast.featured") diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntryKotlinReferenceTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntryKotlinReferenceTest.kt deleted file mode 100644 index 49a6762..0000000 --- a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntryKotlinReferenceTest.kt +++ /dev/null @@ -1,80 +0,0 @@ -package dev.androidbroadcast.featured.gradle - -import kotlin.test.Test -import kotlin.test.assertEquals - -class LocalFlagEntryKotlinReferenceTest { - @Test - fun `kotlinReference for local flag uses GeneratedLocalFlags object`() { - val entry = - LocalFlagEntry( - key = "dark_mode", - defaultValue = "false", - type = "Boolean", - moduleName = ":app", - propertyName = "darkMode", - flagType = LocalFlagEntry.FLAG_TYPE_LOCAL, - ) - assertEquals("${LocalFlagEntry.GENERATED_LOCAL_OBJECT}.darkMode", entry.kotlinReference) - } - - @Test - fun `kotlinReference for remote flag uses GeneratedRemoteFlags object`() { - val entry = - LocalFlagEntry( - key = "promo_banner", - defaultValue = "false", - type = "Boolean", - moduleName = ":app", - propertyName = "promoBanner", - flagType = LocalFlagEntry.FLAG_TYPE_REMOTE, - ) - assertEquals("${LocalFlagEntry.GENERATED_REMOTE_OBJECT}.promoBanner", entry.kotlinReference) - } - - @Test - fun `kotlinReference returns empty string when propertyName is blank`() { - val entry = - LocalFlagEntry( - key = "legacy", - defaultValue = "false", - type = "Boolean", - moduleName = ":app", - propertyName = "", - flagType = LocalFlagEntry.FLAG_TYPE_LOCAL, - ) - assertEquals("", entry.kotlinReference) - } - - @Test - fun `kotlinReference returns empty string for default-constructed entry`() { - val entry = LocalFlagEntry(key = "k", defaultValue = "v", type = "String", moduleName = ":mod") - assertEquals("", entry.kotlinReference) - } - - @Test - fun `isLocal is true for local flagType`() { - val entry = - LocalFlagEntry( - key = "k", - defaultValue = "v", - type = "String", - moduleName = ":mod", - flagType = LocalFlagEntry.FLAG_TYPE_LOCAL, - ) - assertEquals(true, entry.isLocal) - } - - @Test - fun `isLocal is false for remote flagType`() { - val entry = - LocalFlagEntry( - key = "k", - defaultValue = "v", - type = "String", - moduleName = ":mod", - flagType = LocalFlagEntry.FLAG_TYPE_REMOTE, - ) - assertEquals(false, entry.isLocal) - } -} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntryTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntryTest.kt new file mode 100644 index 0000000..1c5651e --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/LocalFlagEntryTest.kt @@ -0,0 +1,32 @@ +package dev.androidbroadcast.featured.gradle + +import kotlin.test.Test +import kotlin.test.assertEquals + +class LocalFlagEntryTest { + @Test + fun `isLocal is true for local flagType`() { + val entry = + LocalFlagEntry( + key = "k", + defaultValue = "v", + type = "String", + moduleName = ":mod", + flagType = LocalFlagEntry.FLAG_TYPE_LOCAL, + ) + assertEquals(true, entry.isLocal) + } + + @Test + fun `isLocal is false for remote flagType`() { + val entry = + LocalFlagEntry( + key = "k", + defaultValue = "v", + type = "String", + moduleName = ":mod", + flagType = LocalFlagEntry.FLAG_TYPE_REMOTE, + ) + assertEquals(false, entry.isLocal) + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGeneratorTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGeneratorTest.kt index 55f7fab..78b24a1 100644 --- a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGeneratorTest.kt +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/ProguardRulesGeneratorTest.kt @@ -8,7 +8,7 @@ import kotlin.test.assertTrue class ProguardRulesGeneratorTest { private val modulePath = ":feature:ui" private val expectedClass = - "dev.androidbroadcast.featured.generated.${ExtensionFunctionGenerator.jvmFileName(modulePath)}" + "dev.androidbroadcast.featured.generated.${ExtensionFunctionGenerator.fileName(modulePath).removeSuffix(".kt")}Kt" // ── empty / no-op cases ────────────────────────────────────────────────── diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationConfigurationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationConfigurationTest.kt new file mode 100644 index 0000000..2624351 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationConfigurationTest.kt @@ -0,0 +1,102 @@ +package dev.androidbroadcast.featured.gradle.aggregation + +import dev.androidbroadcast.featured.gradle.manifest.SCHEMA_VERSION +import dev.androidbroadcast.featured.gradle.manifest.schemaMajorAttr +import org.gradle.api.attributes.Usage +import org.gradle.testfixtures.ProjectBuilder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@Suppress("UnstableApiUsage") +class FeaturedAggregationConfigurationTest { + @Test + fun `featuredAggregation configuration is registered`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + val cfg = project.configurations.findByName(FEATURED_AGGREGATION_CONFIGURATION_NAME) + assertNotNull(cfg, "Expected '$FEATURED_AGGREGATION_CONFIGURATION_NAME' configuration to be registered") + } + + @Test + fun `featuredAggregation is declarable not consumable not resolvable`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + val cfg = project.configurations.findByName(FEATURED_AGGREGATION_CONFIGURATION_NAME) + assertNotNull(cfg) + assertTrue(cfg.isCanBeDeclared, "Expected isCanBeDeclared = true") + assertTrue(!cfg.isCanBeConsumed, "Expected isCanBeConsumed = false") + assertTrue(!cfg.isCanBeResolved, "Expected isCanBeResolved = false") + } + + @Test + fun `featuredAggregationClasspath configuration is registered`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + val cfg = project.configurations.findByName(FEATURED_AGGREGATION_CLASSPATH_CONFIGURATION_NAME) + assertNotNull(cfg, "Expected '$FEATURED_AGGREGATION_CLASSPATH_CONFIGURATION_NAME' configuration to be registered") + } + + @Test + fun `featuredAggregationClasspath is resolvable not consumable not declarable`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + val cfg = project.configurations.findByName(FEATURED_AGGREGATION_CLASSPATH_CONFIGURATION_NAME) + assertNotNull(cfg) + assertTrue(cfg.isCanBeResolved, "Expected isCanBeResolved = true") + assertTrue(!cfg.isCanBeConsumed, "Expected isCanBeConsumed = false") + assertTrue(!cfg.isCanBeDeclared, "Expected isCanBeDeclared = false") + } + + @Test + fun `featuredAggregationClasspath has Usage attribute featured-manifest`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + val cfg = project.configurations.findByName(FEATURED_AGGREGATION_CLASSPATH_CONFIGURATION_NAME) + assertNotNull(cfg) + val usageAttr = cfg.attributes.getAttribute(Usage.USAGE_ATTRIBUTE) + assertNotNull(usageAttr, "Expected Usage attribute to be set") + assertEquals( + "featured-manifest", + usageAttr.name, + "Expected Usage attribute name 'featured-manifest', got '${usageAttr.name}'", + ) + } + + @Test + fun `featuredAggregationClasspath has schema-major attribute equal to SCHEMA_VERSION`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + val cfg = project.configurations.findByName(FEATURED_AGGREGATION_CLASSPATH_CONFIGURATION_NAME) + assertNotNull(cfg) + val schemaAttr = cfg.attributes.getAttribute(schemaMajorAttr) + assertNotNull(schemaAttr, "Expected schema-major attribute to be set") + assertEquals( + SCHEMA_VERSION, + schemaAttr, + "Expected schema-major = $SCHEMA_VERSION, got $schemaAttr", + ) + } + + @Test + fun `featuredAggregationClasspath extends featuredAggregation`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + val declarable = project.configurations.findByName(FEATURED_AGGREGATION_CONFIGURATION_NAME) + val classpath = project.configurations.findByName(FEATURED_AGGREGATION_CLASSPATH_CONFIGURATION_NAME) + assertNotNull(declarable) + assertNotNull(classpath) + assertTrue( + classpath.extendsFrom.contains(declarable), + "Expected featuredAggregationClasspath to extend featuredAggregation", + ) + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDescriptorIntegrityTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDescriptorIntegrityTest.kt new file mode 100644 index 0000000..c43c0d0 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDescriptorIntegrityTest.kt @@ -0,0 +1,217 @@ +package dev.androidbroadcast.featured.gradle.aggregation + +import dev.androidbroadcast.featured.gradle.manifest.FeaturedManifest +import dev.androidbroadcast.featured.gradle.manifest.FlagDescriptor +import dev.androidbroadcast.featured.gradle.manifest.FlagKind +import dev.androidbroadcast.featured.gradle.manifest.SCHEMA_VERSION +import dev.androidbroadcast.featured.gradle.manifest.ValueType +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertFailsWith + +class FeaturedAggregationDescriptorIntegrityTest { + private fun enumFlag( + key: String = "checkout_variant", + enumTypeFqn: String? = "com.example.CheckoutVariant", + defaultValue: String = "LEGACY", + ) = FlagDescriptor( + key = key, + propertyName = key, + kind = FlagKind.LOCAL, + valueType = ValueType.ENUM, + defaultValue = defaultValue, + enumTypeFqn = enumTypeFqn, + ) + + private fun primitiveFlag( + key: String = "some_flag", + valueType: ValueType, + defaultValue: String, + ) = FlagDescriptor( + key = key, + propertyName = key, + kind = FlagKind.LOCAL, + valueType = valueType, + defaultValue = defaultValue, + enumTypeFqn = null, + ) + + private fun singleManifest(flag: FlagDescriptor) = + listOf( + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature-a", + flags = listOf(flag), + ), + ) + + @Test + fun `valid ENUM flag with FQN and identifier constant does not throw`() { + // Sanity: a well-formed manifest passes without exception. + validateFlagDescriptorIntegrity(singleManifest(enumFlag())) + } + + @Test + fun `ENUM flag with semicolon in FQN throws IllegalArgumentException naming key and module`() { + // Simulates a malicious FQN that would inject a Kotlin init block into the generated source. + val maliciousFqn = "kotlin.Unit>(); init { injectCode() }; private val x: ConfigParam { + validateFlagDescriptorIntegrity(singleManifest(enumFlag(enumTypeFqn = maliciousFqn))) + } + val msg = ex.message ?: "" + assertContains(msg, "checkout_variant", message = "Message must name the flag key") + assertContains(msg, ":feature-a", message = "Message must name the module path") + } + + @Test + fun `ENUM flag with angle bracket in FQN throws`() { + assertFailsWith { + validateFlagDescriptorIntegrity(singleManifest(enumFlag(enumTypeFqn = "com.example.Foo"))) + } + } + + @Test + fun `ENUM flag with parenthesis in FQN throws`() { + assertFailsWith { + validateFlagDescriptorIntegrity(singleManifest(enumFlag(enumTypeFqn = "com.example().Foo"))) + } + } + + @Test + fun `ENUM flag with brace in FQN throws`() { + assertFailsWith { + validateFlagDescriptorIntegrity(singleManifest(enumFlag(enumTypeFqn = "com.example{}.Foo"))) + } + } + + @Test + fun `ENUM flag with space in FQN throws`() { + assertFailsWith { + validateFlagDescriptorIntegrity(singleManifest(enumFlag(enumTypeFqn = "com.example .Foo"))) + } + } + + @Test + fun `ENUM flag with Unicode line separator in FQN throws`() { + // U+2028 LINE SEPARATOR — not a valid Kotlin identifier character; must be rejected. + val fqnWithLineSeparator = "com.example
Foo" + assertFailsWith { + validateFlagDescriptorIntegrity(singleManifest(enumFlag(enumTypeFqn = fqnWithLineSeparator))) + } + } + + @Test + fun `ENUM flag with injection in defaultValue throws`() { + // Simulates a malicious constant name that would inject statements into the generated source. + val maliciousDefault = "INSTANCE; injectCode()" + val ex = + assertFailsWith { + validateFlagDescriptorIntegrity(singleManifest(enumFlag(defaultValue = maliciousDefault))) + } + val msg = ex.message ?: "" + assertContains(msg, "checkout_variant", message = "Message must name the flag key") + assertContains(msg, ":feature-a", message = "Message must name the module path") + } + + @Test + fun `ENUM flag with null enumTypeFqn throws IllegalArgumentException naming key and module`() { + val ex = + assertFailsWith { + validateFlagDescriptorIntegrity(singleManifest(enumFlag(enumTypeFqn = null))) + } + val msg = ex.message ?: "" + assertContains(msg, "checkout_variant", message = "Message must name the flag key") + assertContains(msg, ":feature-a", message = "Message must name the module path") + } + + // --- Primitive defaultValue validation tests --- + + @Test + fun `BOOLEAN defaultValue 'true' does not throw`() { + validateFlagDescriptorIntegrity(singleManifest(primitiveFlag(valueType = ValueType.BOOLEAN, defaultValue = "true"))) + } + + @Test + fun `BOOLEAN defaultValue 'false' does not throw`() { + validateFlagDescriptorIntegrity(singleManifest(primitiveFlag(valueType = ValueType.BOOLEAN, defaultValue = "false"))) + } + + @Test + fun `BOOLEAN defaultValue with appended statement throws naming key and module`() { + // Simulates injection of an extra statement appended to the boolean literal. + val ex = + assertFailsWith { + validateFlagDescriptorIntegrity( + singleManifest(primitiveFlag(key = "some_flag", valueType = ValueType.BOOLEAN, defaultValue = "true; init { evil() }")), + ) + } + val msg = ex.message ?: "" + assertContains(msg, "some_flag", message = "Message must name the flag key") + assertContains(msg, ":feature-a", message = "Message must name the module path") + } + + @Test + fun `INT defaultValue with method-call suffix throws`() { + // The exact attack vector from the security review: 0.also { ... } is a valid Kotlin expression + // but must not be emitted verbatim as a ConfigParam defaultValue literal. + val ex = + assertFailsWith { + validateFlagDescriptorIntegrity( + singleManifest(primitiveFlag(key = "some_flag", valueType = ValueType.INT, defaultValue = "0.also { injectCode() }")), + ) + } + val msg = ex.message ?: "" + assertContains(msg, "some_flag", message = "Message must name the flag key") + assertContains(msg, ":feature-a", message = "Message must name the module path") + } + + @Test + fun `INT defaultValue '-42' does not throw`() { + // Negative integers are valid and must be allowed. + validateFlagDescriptorIntegrity(singleManifest(primitiveFlag(valueType = ValueType.INT, defaultValue = "-42"))) + } + + @Test + fun `LONG defaultValue max signed 64-bit value does not throw`() { + validateFlagDescriptorIntegrity( + singleManifest(primitiveFlag(valueType = ValueType.LONG, defaultValue = "9223372036854775807")), + ) + } + + @Test + fun `FLOAT defaultValue '3_14' does not throw`() { + validateFlagDescriptorIntegrity(singleManifest(primitiveFlag(valueType = ValueType.FLOAT, defaultValue = "3.14"))) + } + + @Test + fun `FLOAT defaultValue with non-numeric prefix throws`() { + val ex = + assertFailsWith { + validateFlagDescriptorIntegrity( + singleManifest(primitiveFlag(key = "some_flag", valueType = ValueType.FLOAT, defaultValue = "NaN; injectCode()")), + ) + } + val msg = ex.message ?: "" + assertContains(msg, "some_flag", message = "Message must name the flag key") + assertContains(msg, ":feature-a", message = "Message must name the module path") + } + + @Test + fun `DOUBLE defaultValue scientific notation does not throw`() { + validateFlagDescriptorIntegrity(singleManifest(primitiveFlag(valueType = ValueType.DOUBLE, defaultValue = "1.5e10"))) + } + + @Test + fun `DOUBLE defaultValue with brace injection throws`() { + val ex = + assertFailsWith { + validateFlagDescriptorIntegrity( + singleManifest(primitiveFlag(key = "some_flag", valueType = ValueType.DOUBLE, defaultValue = "1.5} init { evil() }")), + ) + } + val msg = ex.message ?: "" + assertContains(msg, "some_flag", message = "Message must name the flag key") + assertContains(msg, ":feature-a", message = "Message must name the module path") + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDuplicateKeyTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDuplicateKeyTest.kt new file mode 100644 index 0000000..7e7d6f1 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationDuplicateKeyTest.kt @@ -0,0 +1,137 @@ +package dev.androidbroadcast.featured.gradle.aggregation + +import dev.androidbroadcast.featured.gradle.manifest.FeaturedManifest +import dev.androidbroadcast.featured.gradle.manifest.FlagDescriptor +import dev.androidbroadcast.featured.gradle.manifest.FlagKind +import dev.androidbroadcast.featured.gradle.manifest.SCHEMA_VERSION +import dev.androidbroadcast.featured.gradle.manifest.ValueType +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertFailsWith + +class FeaturedAggregationDuplicateKeyTest { + private fun booleanFlag( + key: String, + kind: FlagKind = FlagKind.LOCAL, + ) = FlagDescriptor( + key = key, + propertyName = key, + kind = kind, + valueType = ValueType.BOOLEAN, + defaultValue = "false", + ) + + @Test + fun `no error for unique keys across modules`() { + val manifests = + listOf( + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature-a", + flags = listOf(booleanFlag("dark_mode")), + ), + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature-b", + flags = listOf(booleanFlag("show_banner")), + ), + ) + // Should not throw + validateUniqueKeys(manifests) + } + + @Test + fun `duplicate key across two modules throws with both module paths`() { + val manifests = + listOf( + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature-a", + flags = listOf(booleanFlag("dark_mode")), + ), + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature-b", + flags = listOf(booleanFlag("dark_mode")), + ), + ) + val ex = assertFailsWith { validateUniqueKeys(manifests) } + assertContains(ex.message ?: "", "dark_mode", message = "Message must contain the duplicate key") + assertContains(ex.message ?: "", ":feature-a", message = "Message must name first module path") + assertContains(ex.message ?: "", ":feature-b", message = "Message must name second module path") + } + + @Test + fun `same key in LOCAL and REMOTE of same module is a duplicate`() { + // A single module declaring the same key in both localFlags and remoteFlags. + val manifests = + listOf( + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature-checkout", + flags = + listOf( + booleanFlag(key = "checkout_mode", kind = FlagKind.LOCAL), + booleanFlag(key = "checkout_mode", kind = FlagKind.REMOTE), + ), + ), + ) + val ex = assertFailsWith { validateUniqueKeys(manifests) } + assertContains(ex.message ?: "", "checkout_mode", message = "Message must contain the duplicate key") + // Same-module collision: both LOCAL and REMOTE markers must appear so the origin is distinguishable. + assertContains(ex.message ?: "", "LOCAL", message = "Message must name LOCAL kind") + assertContains(ex.message ?: "", "REMOTE", message = "Message must name REMOTE kind") + assertContains(ex.message ?: "", ":feature-checkout", message = "Message must name module path") + } + + @Test + fun `three modules colliding on same key all appear in error message`() { + val manifests = + listOf( + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature-a", + flags = listOf(booleanFlag("shared_flag")), + ), + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature-b", + flags = listOf(booleanFlag("shared_flag")), + ), + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature-c", + flags = listOf(booleanFlag("shared_flag")), + ), + ) + val ex = assertFailsWith { validateUniqueKeys(manifests) } + val msg = ex.message ?: "" + assertContains(msg, "shared_flag", message = "Message must contain the duplicate key") + assertContains(msg, ":feature-a", message = "Message must name :feature-a") + assertContains(msg, ":feature-b", message = "Message must name :feature-b") + assertContains(msg, ":feature-c", message = "Message must name :feature-c") + } + + @Test + fun `same module LOCAL and REMOTE collision shows both LOCAL and REMOTE not just module path twice`() { + // Regression guard: before the fix the message read "':feature-checkout' and ':feature-checkout'" + // with no kind information — indistinguishable from a cross-module collision with identical names. + val manifests = + listOf( + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature-checkout", + flags = + listOf( + booleanFlag(key = "show_avatar", kind = FlagKind.LOCAL), + booleanFlag(key = "show_avatar", kind = FlagKind.REMOTE), + ), + ), + ) + val ex = assertFailsWith { validateUniqueKeys(manifests) } + val msg = ex.message ?: "" + assertContains(msg, "show_avatar", message = "Message must contain the duplicate key") + assertContains(msg, "LOCAL", message = "Message must include LOCAL kind marker") + assertContains(msg, "REMOTE", message = "Message must include REMOTE kind marker") + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationIntegrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationIntegrationTest.kt new file mode 100644 index 0000000..84de5c3 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationIntegrationTest.kt @@ -0,0 +1,198 @@ +package dev.androidbroadcast.featured.gradle.aggregation + +import dev.androidbroadcast.featured.gradle.manifest.androidSdkDirOrNull +import dev.androidbroadcast.featured.gradle.manifest.copyManifestFixture +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Integration tests for the multi-module registry aggregation using the + * `aggregator-multi-module-project` fixture (two Android library modules + aggregating app). + * + * Skipped when `ANDROID_HOME` / `ANDROID_SDK_ROOT` is not set. + */ +class FeaturedAggregationIntegrationTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var projectDir: File + + @Before + fun setUp() { + val sdkDir = androidSdkDirOrNull() + assumeTrue( + "ANDROID_HOME or ANDROID_SDK_ROOT must be set to run integration tests", + sdkDir != null, + ) + + projectDir = tempFolder.newFolder("aggregator-multi-module-project") + copyManifestFixture(fixtureName = "aggregator-multi-module-project", dest = projectDir) + + // Write local.properties with the real SDK path — use invariantSeparatorsPath so that + // a raw Windows SDK path would not corrupt local.properties. + projectDir.resolve("local.properties").writeText("sdk.dir=${sdkDir!!.invariantSeparatorsPath}\n") + } + + @Test + fun `generateFeaturedRegistry succeeds`() { + val result = + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME", "--stacktrace") + .build() + + val outcome = result.task(":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME")?.outcome + assertEquals( + TaskOutcome.SUCCESS, + outcome, + "Expected :app:$GENERATE_FEATURED_REGISTRY_TASK_NAME to succeed, got $outcome\n${result.output}", + ) + } + + @Test + fun `generated file exists at expected path`() { + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME") + .build() + + val generatedFile = + projectDir.resolve( + "app/build/generated/featured/commonMain/${FEATURED_REGISTRY_OBJECT}.kt", + ) + assertTrue(generatedFile.exists(), "Expected generated file at ${generatedFile.path}") + } + + @Test + fun `generated source contains expected ConfigParam entries`() { + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME") + .build() + + val source = + projectDir + .resolve("app/build/generated/featured/commonMain/${FEATURED_REGISTRY_OBJECT}.kt") + .readText() + + assertTrue(source.contains("object $FEATURED_REGISTRY_OBJECT"), "Missing object declaration") + assertTrue(source.contains("listOf("), "Missing listOf() in generated source") + assertTrue( + source.contains("ConfigParam(key = \"dark_mode\""), + "Missing dark_mode (Boolean) entry", + ) + assertTrue( + source.contains("ConfigParam(key = \"checkout_variant\""), + "Missing checkout_variant (ENUM) entry", + ) + assertTrue( + source.contains("ConfigParam(key = \"show_avatar\""), + "Missing show_avatar (Boolean) entry", + ) + assertTrue( + source.contains("ConfigParam(key = \"avatar_placeholder\""), + "Missing avatar_placeholder (String) entry", + ) + } + + @Test + fun `second run without changes reports UP_TO_DATE`() { + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME") + .build() + + val result = + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME") + .build() + + val outcome = result.task(":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME")?.outcome + assertTrue( + outcome == TaskOutcome.UP_TO_DATE || outcome == TaskOutcome.FROM_CACHE, + "Expected UP_TO_DATE or FROM_CACHE on second run, got $outcome", + ) + } + + @Test + fun `mutating a feature module invalidates the registry task`() { + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME") + .build() + + // Add a new flag to :feature-checkout to invalidate the manifest artifact. + val buildFile = projectDir.resolve("feature-checkout/build.gradle.kts") + buildFile.writeText( + buildFile.readText().replace( + "enum(\"checkout_variant\", typeFqn = \"com.example.CheckoutVariant\", default = \"LEGACY\")", + "enum(\"checkout_variant\", typeFqn = \"com.example.CheckoutVariant\", default = \"LEGACY\")\n" + + " int(\"max_retries\", default = 3)", + ), + ) + + val result = + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME") + .build() + + val outcome = result.task(":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME")?.outcome + assertEquals( + TaskOutcome.SUCCESS, + outcome, + "Expected SUCCESS after input change, got $outcome", + ) + } + + @Test + fun `configuration cache stores on first run`() { + val result = + gradleRunner() + .withArguments( + ":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME", + "--configuration-cache", + "--configuration-cache-problems=warn", + ).build() + + assertTrue( + result.output.contains("Configuration cache entry stored"), + "Expected 'Configuration cache entry stored' in output, got:\n${result.output}", + ) + } + + @Test + fun `configuration cache is reused on second run`() { + gradleRunner() + .withArguments( + ":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME", + "--configuration-cache", + "--configuration-cache-problems=warn", + ).build() + + val secondRun = + gradleRunner() + .withArguments( + ":app:$GENERATE_FEATURED_REGISTRY_TASK_NAME", + "--configuration-cache", + "--configuration-cache-problems=warn", + ).build() + + assertTrue( + secondRun.output.contains("Configuration cache entry reused") || + secondRun.output.contains("Reusing configuration cache"), + "Expected CC reuse marker in second-run output, got:\n${secondRun.output}", + ) + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private fun gradleRunner(): GradleRunner = + GradleRunner + .create() + .withProjectDir(projectDir) + .withPluginClasspath() + .forwardOutput() +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationParseErrorTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationParseErrorTest.kt new file mode 100644 index 0000000..bb4f85b --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/FeaturedAggregationParseErrorTest.kt @@ -0,0 +1,46 @@ +package dev.androidbroadcast.featured.gradle.aggregation + +import org.gradle.testfixtures.ProjectBuilder +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertFailsWith + +/** + * Verifies that a corrupt or malformed manifest file produces an [IllegalStateException] + * whose message includes the file path so the developer can locate the bad file immediately. + * + * Paired with Fix 3 in GenerateFeaturedRegistryTask. + */ +@Suppress("UnstableApiUsage") +class FeaturedAggregationParseErrorTest { + @Test + fun `malformed manifest json produces IllegalStateException containing file path`() { + val tempDir = Files.createTempDirectory("featured-parse-error-test").toFile() + try { + val badManifest = + File(tempDir, "featured-manifest.json").also { + it.writeText("""{ "broken": json""") + } + val outputFile = File(tempDir, "GeneratedFeaturedRegistry.kt") + + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + val task = project.tasks.findByName(GENERATE_FEATURED_REGISTRY_TASK_NAME) as GenerateFeaturedRegistryTask + task.manifestFiles.from(badManifest) + task.outputPackage.set(FEATURED_REGISTRY_PACKAGE) + task.outputFile.set(outputFile) + + val ex = assertFailsWith { task.generate() } + assertContains( + ex.message ?: "", + badManifest.path, + message = "Exception message must include the path of the malformed manifest file", + ) + } finally { + tempDir.deleteRecursively() + } + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTaskRegistrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTaskRegistrationTest.kt new file mode 100644 index 0000000..0cde118 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GenerateFeaturedRegistryTaskRegistrationTest.kt @@ -0,0 +1,90 @@ +package dev.androidbroadcast.featured.gradle.aggregation + +import org.gradle.testfixtures.ProjectBuilder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@Suppress("UnstableApiUsage") +class GenerateFeaturedRegistryTaskRegistrationTest { + @Test + fun `plugin registers generateFeaturedRegistry task`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + assertTrue( + project.tasks.names.contains(GENERATE_FEATURED_REGISTRY_TASK_NAME), + "Expected '$GENERATE_FEATURED_REGISTRY_TASK_NAME' task to be registered lazily by the plugin", + ) + } + + @Test + fun `generateFeaturedRegistry task is of correct type`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + val task = project.tasks.findByName(GENERATE_FEATURED_REGISTRY_TASK_NAME) + assertNotNull(task, "Expected '$GENERATE_FEATURED_REGISTRY_TASK_NAME' task to be registered") + assertTrue( + task is GenerateFeaturedRegistryTask, + "Expected task type GenerateFeaturedRegistryTask but was ${task::class.simpleName}", + ) + } + + @Test + fun `generateFeaturedRegistry task is in featured group`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + val task = project.tasks.findByName(GENERATE_FEATURED_REGISTRY_TASK_NAME) as? GenerateFeaturedRegistryTask + assertNotNull(task) + assertEquals("featured", task.group, "Expected task group 'featured' but was '${task.group}'") + } + + @Test + fun `generateFeaturedRegistry task outputPackage defaults to FEATURED_REGISTRY_PACKAGE`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + val task = project.tasks.findByName(GENERATE_FEATURED_REGISTRY_TASK_NAME) as? GenerateFeaturedRegistryTask + assertNotNull(task) + assertEquals( + FEATURED_REGISTRY_PACKAGE, + task.outputPackage.get(), + "Expected outputPackage == FEATURED_REGISTRY_PACKAGE", + ) + } + + @Test + fun `generateFeaturedRegistry task outputFile path follows convention`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + val task = project.tasks.findByName(GENERATE_FEATURED_REGISTRY_TASK_NAME) as? GenerateFeaturedRegistryTask + assertNotNull(task) + val outputPath = + task.outputFile + .get() + .asFile.path + assertTrue( + outputPath.endsWith("build/generated/featured/commonMain/${FEATURED_REGISTRY_OBJECT}.kt"), + "Expected outputFile to end with 'build/generated/featured/commonMain/${FEATURED_REGISTRY_OBJECT}.kt', got: $outputPath", + ) + } + + @Test + fun `accessing featuredAggregationClasspath configuration does not eagerly realize generateFeaturedRegistry task`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured.application") + + // Accessing the configuration by name must not trigger task realization. + project.configurations.getByName(FEATURED_AGGREGATION_CLASSPATH_CONFIGURATION_NAME) + + // The task must still be present in the task graph (registered lazily). + assertTrue( + project.tasks.names.contains(GENERATE_FEATURED_REGISTRY_TASK_NAME), + "Expected '$GENERATE_FEATURED_REGISTRY_TASK_NAME' to be in task names (lazy)", + ) + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGeneratorTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGeneratorTest.kt new file mode 100644 index 0000000..bd73c34 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/aggregation/GeneratedFeaturedRegistryGeneratorTest.kt @@ -0,0 +1,393 @@ +package dev.androidbroadcast.featured.gradle.aggregation + +import dev.androidbroadcast.featured.gradle.manifest.FeaturedManifest +import dev.androidbroadcast.featured.gradle.manifest.FlagDescriptor +import dev.androidbroadcast.featured.gradle.manifest.FlagKind +import dev.androidbroadcast.featured.gradle.manifest.SCHEMA_VERSION +import dev.androidbroadcast.featured.gradle.manifest.ValueType +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class GeneratedFeaturedRegistryGeneratorTest { + private fun manifest( + modulePath: String, + vararg flags: FlagDescriptor, + ): FeaturedManifest = + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = modulePath, + flags = flags.toList(), + ) + + private fun flag( + key: String, + valueType: ValueType, + defaultValue: String, + kind: FlagKind = FlagKind.LOCAL, + enumTypeFqn: String? = null, + description: String? = null, + category: String? = null, + ) = FlagDescriptor( + key = key, + propertyName = key, + kind = kind, + valueType = valueType, + defaultValue = defaultValue, + enumTypeFqn = enumTypeFqn, + description = description, + category = category, + ) + + @Test + fun `empty manifests list produces emptyList body`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = emptyList(), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, "emptyList()") + assertFalse(source.contains("listOf("), "Expected no listOf when empty") + } + + @Test + fun `single BOOLEAN local flag emits correct ConfigParam`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = listOf(manifest(":app", flag(key = "dark_mode", valueType = ValueType.BOOLEAN, defaultValue = "false"))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, "ConfigParam(key = \"dark_mode\", defaultValue = false)") + } + + @Test + fun `LONG suffix is L`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = listOf(manifest(":app", flag(key = "timeout", valueType = ValueType.LONG, defaultValue = "123"))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, "defaultValue = 123L") + } + + @Test + fun `FLOAT suffix is f`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = listOf(manifest(":app", flag(key = "ratio", valueType = ValueType.FLOAT, defaultValue = "1.5"))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, "defaultValue = 1.5f") + } + + @Test + fun `DOUBLE emits raw value`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = listOf(manifest(":app", flag(key = "pi", valueType = ValueType.DOUBLE, defaultValue = "3.14"))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, "defaultValue = 3.14") + assertFalse(source.contains("3.14f"), "DOUBLE must not have f suffix") + assertFalse(source.contains("3.14L"), "DOUBLE must not have L suffix") + } + + @Test + fun `INT emits raw value`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = listOf(manifest(":app", flag(key = "retries", valueType = ValueType.INT, defaultValue = "3"))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, "defaultValue = 3") + assertFalse(source.contains("3L"), "INT must not have L suffix") + } + + @Test + fun `STRING re-wraps bare value in quotes`() { + // Producer stores bare value: "hello world" (no surrounding quotes) + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = listOf(manifest(":app", flag(key = "label", valueType = ValueType.STRING, defaultValue = "hello world"))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, "defaultValue = \"hello world\"") + } + + @Test + fun `STRING escapes embedded double quotes`() { + // Producer stores bare: say "hi" — generator must emit: "say \"hi\"" + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = listOf(manifest(":app", flag(key = "greeting", valueType = ValueType.STRING, defaultValue = """say "hi""""))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, """defaultValue = "say \"hi\"""") + } + + @Test + fun `ENUM emits enumTypeFqn dot constant as default and type arg`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = + listOf( + manifest( + ":feature", + flag( + key = "checkout_variant", + valueType = ValueType.ENUM, + defaultValue = "LEGACY", + enumTypeFqn = "com.example.CheckoutVariant", + ), + ), + ), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, "ConfigParam") + assertContains(source, "defaultValue = com.example.CheckoutVariant.LEGACY") + assertContains(source, "enumConstants = kotlin.enums.enumEntries()") + } + + @Test + fun `BOOLEAN flag does not emit enumConstants`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = listOf(manifest(":app", flag(key = "dark_mode", valueType = ValueType.BOOLEAN, defaultValue = "false"))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertFalse(source.contains("enumConstants"), "enumConstants must not appear for non-enum types") + } + + @Test + fun `multi-module input lists all flags`() { + val moduleA = + manifest( + ":feature-a", + flag(key = "flag_a1", valueType = ValueType.BOOLEAN, defaultValue = "true"), + flag(key = "flag_a2", valueType = ValueType.INT, defaultValue = "1"), + ) + val moduleB = + manifest( + ":feature-b", + flag(key = "flag_b1", valueType = ValueType.STRING, defaultValue = "hello"), + flag(key = "flag_b2", valueType = ValueType.LONG, defaultValue = "99"), + ) + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = listOf(moduleA, moduleB), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, "flag_a1") + assertContains(source, "flag_a2") + assertContains(source, "flag_b1") + assertContains(source, "flag_b2") + } + + @Test + fun `stable order manifests in B-A input produce flags sorted by modulePath then key`() { + // Manifests passed in [B, A] order — output must be A's flags first, then B's. + val moduleA = + manifest( + ":feature-a", + flag(key = "z_flag", valueType = ValueType.BOOLEAN, defaultValue = "false"), + flag(key = "a_flag", valueType = ValueType.BOOLEAN, defaultValue = "true"), + ) + val moduleB = + manifest( + ":feature-b", + flag(key = "m_flag", valueType = ValueType.INT, defaultValue = "5"), + ) + // Pass B before A intentionally + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = listOf(moduleB, moduleA), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + val aFlagPos = source.indexOf("a_flag") + val zFlagPos = source.indexOf("z_flag") + val mFlagPos = source.indexOf("m_flag") + + // :feature-a < :feature-b alphabetically; within :feature-a, a_flag < z_flag + assertTrue(aFlagPos < zFlagPos, "a_flag must appear before z_flag (within :feature-a)") + assertTrue(zFlagPos < mFlagPos, "z_flag (:feature-a) must appear before m_flag (:feature-b)") + } + + @Test + fun `optional description is emitted when non-null`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = + listOf( + manifest( + ":app", + flag( + key = "my_flag", + valueType = ValueType.BOOLEAN, + defaultValue = "true", + description = "Controls the widget", + ), + ), + ), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, "description = \"Controls the widget\"") + } + + @Test + fun `null description is omitted from ConfigParam args`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = + listOf( + manifest(":app", flag(key = "my_flag", valueType = ValueType.BOOLEAN, defaultValue = "false")), + ), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertFalse(source.contains("description ="), "description must be absent when null") + } + + @Test + fun `since parameter is never emitted`() { + // Manifest schema v1 has no since field; ConfigParam accepts it but we never emit it + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = + listOf( + manifest(":app", flag(key = "feature", valueType = ValueType.BOOLEAN, defaultValue = "true")), + ), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertFalse(source.contains("since ="), "since must never be emitted") + } + + // NIT 4 — escape paths for STRING default: backslash and dollar sign + + @Test + fun `STRING default with backslash is escaped`() { + // Producer stores bare: path\to\file — generator must emit: "path\\to\\file" + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = + listOf(manifest(":app", flag(key = "path_flag", valueType = ValueType.STRING, defaultValue = """path\to\file"""))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, """defaultValue = "path\\to\\file"""") + } + + @Test + fun `STRING default with dollar sign is escaped to prevent template interpolation`() { + // Producer stores bare: price $9.99 — generator must emit: "price ${'$'}9.99" + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = + listOf(manifest(":app", flag(key = "price_flag", valueType = ValueType.STRING, defaultValue = "price \$9.99"))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + // The generated source must contain the Kotlin-safe form that prevents interpolation. + assertContains(source, "price \${'\$'}9.99") + } + + @Test + fun `key containing double quote is escaped in generated source`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = + listOf(manifest(":app", flag(key = """dark"mode""", valueType = ValueType.BOOLEAN, defaultValue = "false"))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + // Generated key must have the quote escaped: key = "dark\"mode" + assertContains(source, """key = "dark\"mode"""") + } + + @Test + fun `description containing dollar sign is escaped`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = + listOf( + manifest( + ":app", + flag( + key = "promo", + valueType = ValueType.BOOLEAN, + defaultValue = "true", + description = "Price: \$9.99", + ), + ), + ), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, "Price: \${'\$'}9.99") + } + + // Fix 1 — newline / tab escape in STRING default and description + + @Test + fun `STRING default with newline is escaped to backslash-n`() { + // Producer stores a value with a real newline character; generated source must not contain a raw newline. + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = listOf(manifest(":app", flag(key = "multiline", valueType = ValueType.STRING, defaultValue = "line1\nline2"))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, """defaultValue = "line1\nline2"""") + assertFalse(source.contains("line1\nline2"), "Raw newline must not appear in the generated source") + } + + @Test + fun `description with newline is escaped to backslash-n`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = + listOf( + manifest( + ":app", + flag( + key = "flag", + valueType = ValueType.BOOLEAN, + defaultValue = "false", + description = "first line\nsecond line", + ), + ), + ), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, """description = "first line\nsecond line"""") + assertFalse(source.contains("first line\nsecond line"), "Raw newline must not appear in description") + } + + @Test + fun `STRING default with tab is escaped to backslash-t`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = listOf(manifest(":app", flag(key = "tabbed", valueType = ValueType.STRING, defaultValue = "col1\tcol2"))), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, """defaultValue = "col1\tcol2"""") + assertFalse(source.contains("col1\tcol2"), "Raw tab must not appear in the generated source") + } + + // NIT 5 — category emit/omit + + @Test + fun `optional category is emitted when non-null`() { + val source = + GeneratedFeaturedRegistryGenerator.generate( + manifests = + listOf( + manifest( + ":app", + flag( + key = "dark_mode", + valueType = ValueType.BOOLEAN, + defaultValue = "false", + category = "UI", + ), + ), + ), + packageName = FEATURED_REGISTRY_PACKAGE, + ) + assertContains(source, "category = \"UI\"") + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedKmpPublicationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedKmpPublicationTest.kt new file mode 100644 index 0000000..a8a0686 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedKmpPublicationTest.kt @@ -0,0 +1,73 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Smoke test that verifies the `featuredManifest` consumable configuration does NOT leak + * into the published Gradle Module Metadata (`.module` JSON) for a KMP module. + * + * Custom consumable configurations with arbitrary `Usage` attributes are not auto-published + * by the `kotlinMultiplatform`, `java`, `java-library`, or AGP software components — each + * component exposes only the variants it explicitly added via `addVariantsFromConfiguration`. + * This test is the mandatory gate that confirms that invariant in practice for KMP. + * + * Uses the `kmp-publish-project` fixture (JVM-only KMP module) to avoid requiring the + * Kotlin/Native toolchain download that `iosX64()` would trigger on CI. + */ +class FeaturedKmpPublicationTest { + @get:Rule + val tempFolder = TemporaryFolder() + + @Test + fun `publishing KMP module does not expose featuredManifest variant in module metadata`() { + val projectDir = tempFolder.newFolder("kmp-publish-project") + copyManifestFixture("kmp-publish-project", projectDir) + + val result = + GradleRunner + .create() + .withProjectDir(projectDir) + .withPluginClasspath() + .withArguments(":module:publishAllPublicationsToTestLocalRepository", "--stacktrace") + .forwardOutput() + .build() + + val outcome = result.task(":module:publishAllPublicationsToTestLocalRepository")?.outcome + assertTrue( + outcome == TaskOutcome.SUCCESS || outcome == TaskOutcome.UP_TO_DATE, + "Expected publish task to succeed, got $outcome\n${result.output}", + ) + + // Locate the generated .module file in the test-local repo. + val repoDir = projectDir.resolve("module/build/test-repo") + val moduleFiles = repoDir.walkTopDown().filter { it.extension == "module" }.toList() + assertTrue( + moduleFiles.isNotEmpty(), + "Expected at least one .module file in ${repoDir.path}; found none.\n${result.output}", + ) + + moduleFiles.forEach { moduleFile -> + val moduleJson = moduleFile.readText() + + // The featuredManifest Usage must not appear in any published variant. + assertFalse( + moduleJson.contains(FEATURED_MANIFEST_USAGE), + "Found '$FEATURED_MANIFEST_USAGE' in published .module metadata at ${moduleFile.path}.\n" + + "The featuredManifest configuration must be excluded from Maven publication.\n" + + "Content:\n$moduleJson", + ) + + // Sanity check: the .module file is valid and has variants. + assertTrue( + moduleJson.contains("\"variants\""), + "Expected 'variants' key in .module metadata at ${moduleFile.path}", + ) + } + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestConfigurationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestConfigurationTest.kt new file mode 100644 index 0000000..5a0a5c1 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestConfigurationTest.kt @@ -0,0 +1,109 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import org.gradle.api.attributes.Usage +import org.gradle.testfixtures.ProjectBuilder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@Suppress("UnstableApiUsage") +class FeaturedManifestConfigurationTest { + @Test + fun `featuredManifest configuration is registered`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val cfg = project.configurations.findByName(FEATURED_MANIFEST_CONFIGURATION_NAME) + assertNotNull(cfg, "Expected '$FEATURED_MANIFEST_CONFIGURATION_NAME' configuration to be registered") + } + + @Test + fun `featuredManifest configuration has correct consumable flags`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val cfg = project.configurations.findByName(FEATURED_MANIFEST_CONFIGURATION_NAME) + assertNotNull(cfg) + assertTrue(cfg.isCanBeConsumed, "Expected isCanBeConsumed = true") + assertTrue(!cfg.isCanBeResolved, "Expected isCanBeResolved = false") + assertTrue(!cfg.isCanBeDeclared, "Expected isCanBeDeclared = false") + } + + @Test + fun `featuredManifest configuration has usage attribute set to featured-manifest`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val cfg = project.configurations.findByName(FEATURED_MANIFEST_CONFIGURATION_NAME) + assertNotNull(cfg) + val usageAttr = cfg.attributes.getAttribute(Usage.USAGE_ATTRIBUTE) + assertNotNull(usageAttr, "Expected Usage attribute to be set") + assertEquals( + FEATURED_MANIFEST_USAGE, + usageAttr.name, + "Expected usage name '$FEATURED_MANIFEST_USAGE' but was '${usageAttr.name}'", + ) + } + + @Test + fun `featuredManifest configuration has schema-major attribute set to SCHEMA_VERSION`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val cfg = project.configurations.findByName(FEATURED_MANIFEST_CONFIGURATION_NAME) + assertNotNull(cfg) + val schemaAttr = cfg.attributes.getAttribute(schemaMajorAttr) + assertNotNull(schemaAttr, "Expected schema-major attribute to be set") + assertEquals( + SCHEMA_VERSION, + schemaAttr, + "Expected schema-major attribute = $SCHEMA_VERSION but was $schemaAttr", + ) + } + + @Test + fun `featuredManifest configuration has outgoing artifacts`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val cfg = project.configurations.findByName(FEATURED_MANIFEST_CONFIGURATION_NAME) + assertNotNull(cfg) + assertTrue( + cfg.outgoing.artifacts.isNotEmpty(), + "Expected at least one outgoing artifact on '$FEATURED_MANIFEST_CONFIGURATION_NAME'", + ) + } + + @Test + fun `featuredManifest artifact is built by generateFeaturedManifest task`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val cfg = project.configurations.findByName(FEATURED_MANIFEST_CONFIGURATION_NAME) + assertNotNull(cfg) + val deps = + cfg.outgoing.artifacts.buildDependencies + .getDependencies(null) + val taskNames = deps.map { it.name } + assertTrue( + taskNames.contains(GENERATE_FEATURED_MANIFEST_TASK_NAME), + "Expected artifact built by '$GENERATE_FEATURED_MANIFEST_TASK_NAME', got: $taskNames", + ) + } + + @Test + fun `accessing featuredManifest configuration does not eagerly realize generateFeaturedManifest task`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + // Accessing the configuration by name must not trigger task realization. + project.configurations.findByName(FEATURED_MANIFEST_CONFIGURATION_NAME) + + // The task must still be present in the task graph (registered lazily). + assertTrue( + project.tasks.names.contains(GENERATE_FEATURED_MANIFEST_TASK_NAME), + "Expected '$GENERATE_FEATURED_MANIFEST_TASK_NAME' to be in task names (lazy)", + ) + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestEmptyDslTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestEmptyDslTest.kt new file mode 100644 index 0000000..500abbc --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestEmptyDslTest.kt @@ -0,0 +1,62 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Verifies that applying the Featured plugin without any `featured { }` DSL block generates + * a manifest with an empty `flags` array (not omitted) and the correct `schemaVersion`. + * + * Uses Gradle TestKit because `afterEvaluate` (which wires the DSL into the resolve task) + * is not triggered by ProjectBuilder — only a real Gradle execution resolves the full lifecycle. + */ +class FeaturedManifestEmptyDslTest { + @get:Rule + val tempFolder = TemporaryFolder() + + @Test + fun `generateFeaturedManifest with no DSL block produces manifest with empty flags array`() { + val projectDir = tempFolder.newFolder("jvm-empty-featured-project") + copyManifestFixture("jvm-empty-featured-project", projectDir) + + val result = + GradleRunner + .create() + .withProjectDir(projectDir) + .withPluginClasspath() + .withArguments(GENERATE_FEATURED_MANIFEST_TASK_NAME, "--stacktrace") + .forwardOutput() + .build() + + val outcome = result.task(":$GENERATE_FEATURED_MANIFEST_TASK_NAME")?.outcome + assertEquals( + TaskOutcome.SUCCESS, + outcome, + "Expected :$GENERATE_FEATURED_MANIFEST_TASK_NAME to succeed, got $outcome\n${result.output}", + ) + + val manifestFile = projectDir.resolve("build/featured/featured-manifest.json") + assertTrue(manifestFile.exists(), "Expected featured-manifest.json to be generated at ${manifestFile.path}") + + val rawJson = manifestFile.readText() + + // Parse and verify schema. + val manifest = FeaturedManifestJson.decodeFromString(rawJson) + assertEquals(SCHEMA_VERSION, manifest.schemaVersion) + // Plugin is applied to the rootProject in this single-module fixture, so the + // captured Project.path is ":". This verifies the contract for root-project apply. + assertEquals(":", manifest.modulePath, "Expected modulePath ':' for root project apply") + assertTrue(manifest.flags.isEmpty(), "Expected empty flags list, got: ${manifest.flags}") + + // Verify the raw JSON contains "flags": [] explicitly — not omitted. + assertTrue( + rawJson.contains("\"flags\": []"), + "Expected 'flags': [] in raw JSON — empty list must not be omitted, got:\n$rawJson", + ) + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt new file mode 100644 index 0000000..be7c28e --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt @@ -0,0 +1,187 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Integration tests for the per-module Featured manifest generation using the + * `manifest-publish-project` fixture (Android library with local and remote flags). + * + * Skipped when `ANDROID_HOME` / `ANDROID_SDK_ROOT` is not set. + */ +class FeaturedManifestIntegrationTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var projectDir: File + + @Before + fun setUp() { + val sdkDir = androidSdkDirOrNull() + assumeTrue( + "ANDROID_HOME or ANDROID_SDK_ROOT must be set to run integration tests", + sdkDir != null, + ) + + projectDir = tempFolder.newFolder("manifest-publish-project") + copyManifestFixture("manifest-publish-project", projectDir) + // invariantSeparatorsPath replaces backslashes with forward slashes — Java's `.properties` + // parser treats backslashes as escape characters, so a raw Windows SDK path would corrupt + // local.properties. + projectDir.resolve("local.properties").writeText("sdk.dir=${sdkDir!!.invariantSeparatorsPath}\n") + } + + @Test + fun `generateFeaturedManifest produces manifest with correct content`() { + val result = + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME", "--stacktrace") + .build() + + val outcome = result.task(":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME")?.outcome + assertEquals( + TaskOutcome.SUCCESS, + outcome, + "Expected :app:$GENERATE_FEATURED_MANIFEST_TASK_NAME to succeed, got $outcome\n${result.output}", + ) + + val manifest = readManifest() + assertEquals(SCHEMA_VERSION, manifest.schemaVersion) + assertEquals(":app", manifest.modulePath) + assertEquals(3, manifest.flags.size, "Expected 3 flags (dark_mode, checkout_variant, promo_banner)") + + val darkMode = manifest.flags.first { it.key == "dark_mode" } + assertEquals(FlagKind.LOCAL, darkMode.kind) + assertEquals(ValueType.BOOLEAN, darkMode.valueType) + + val promoBanner = manifest.flags.first { it.key == "promo_banner" } + assertEquals(FlagKind.REMOTE, promoBanner.kind) + assertEquals(ValueType.BOOLEAN, promoBanner.valueType) + + val checkoutVariant = manifest.flags.first { it.key == "checkout_variant" } + assertEquals(FlagKind.LOCAL, checkoutVariant.kind) + assertEquals(ValueType.ENUM, checkoutVariant.valueType) + assertEquals("com.example.CheckoutVariant", checkoutVariant.enumTypeFqn) + } + + @Test + fun `second run without changes reports UP_TO_DATE`() { + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME") + .build() + + val result = + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME") + .build() + + val outcome = result.task(":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME")?.outcome + assertTrue( + outcome == TaskOutcome.UP_TO_DATE || outcome == TaskOutcome.FROM_CACHE, + "Expected :app:$GENERATE_FEATURED_MANIFEST_TASK_NAME to be UP_TO_DATE or FROM_CACHE on second run, got $outcome", + ) + } + + @Test + fun `adding a new flag invalidates the task`() { + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME") + .build() + + // Append a new local flag to the app build script to invalidate inputs. + val buildFile = projectDir.resolve("app/build.gradle.kts") + buildFile.writeText( + buildFile.readText().replace( + "enum(\"checkout_variant\", typeFqn = \"com.example.CheckoutVariant\", default = \"LEGACY\")", + "enum(\"checkout_variant\", typeFqn = \"com.example.CheckoutVariant\", default = \"LEGACY\")\n" + + " int(\"max_retries\", default = 3)", + ), + ) + + val result = + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME") + .build() + + val outcome = result.task(":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME")?.outcome + assertEquals( + TaskOutcome.SUCCESS, + outcome, + "Expected :app:$GENERATE_FEATURED_MANIFEST_TASK_NAME to re-run after input change, got $outcome", + ) + + val manifest = readManifest() + assertEquals(4, manifest.flags.size, "Expected 4 flags after adding max_retries") + } + + @Test + fun `configuration cache stores on first run`() { + val result = + gradleRunner() + .withArguments( + ":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME", + "--configuration-cache", + "--configuration-cache-problems=warn", + ).build() + + // Gradle does not create build/reports/configuration-cache/ unless there are CC problems + // to report. The canonical signal that the cache was stored is the output line. + assertTrue( + result.output.contains("Configuration cache entry stored"), + "Expected 'Configuration cache entry stored' in output, got:\n${result.output}", + ) + } + + @Test + fun `configuration cache is reused on second run`() { + gradleRunner() + .withArguments( + ":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME", + "--configuration-cache", + "--configuration-cache-problems=warn", + ).build() + + val secondRun = + gradleRunner() + .withArguments( + ":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME", + "--configuration-cache", + "--configuration-cache-problems=warn", + ).build() + + assertTrue( + secondRun.output.contains("Configuration cache entry reused") || + secondRun.output.contains("Reusing configuration cache"), + "Expected CC reuse marker in second-run output, got:\n${secondRun.output}", + ) + } + + // Configuration exposure (consumable flags, Usage / schema-major attributes, outgoing + // artifact and task dependency) is covered by FeaturedManifestConfigurationTest via + // ProjectBuilder — verifying that here through `:outgoingVariants` triggers a known + // ConcurrentModificationException in AGP 9.1.0 when Android's per-variant configurations + // are iterated alongside our consumable one. + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private fun readManifest(): FeaturedManifest { + val file = projectDir.resolve("app/build/featured/featured-manifest.json") + assertTrue(file.exists(), "Expected featured-manifest.json at ${file.path}") + return FeaturedManifestJson.decodeFromString(file.readText()) + } + + private fun gradleRunner(): GradleRunner = + GradleRunner + .create() + .withProjectDir(projectDir) + .withPluginClasspath() + .forwardOutput() +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestMappingTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestMappingTest.kt new file mode 100644 index 0000000..200fd84 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestMappingTest.kt @@ -0,0 +1,250 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import dev.androidbroadcast.featured.gradle.LocalFlagEntry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FeaturedManifestMappingTest { + // ── ValueType mapping ────────────────────────────────────────────────────── + + @Test + fun `Boolean type maps to BOOLEAN ValueType`() { + val entry = localEntry(key = "flag", type = "Boolean", defaultValue = "false") + val descriptor = entry.toFlagDescriptor() + assertEquals(ValueType.BOOLEAN, descriptor.valueType) + } + + @Test + fun `Int type maps to INT ValueType`() { + val entry = localEntry(key = "flag", type = "Int", defaultValue = "0") + val descriptor = entry.toFlagDescriptor() + assertEquals(ValueType.INT, descriptor.valueType) + } + + @Test + fun `Long type maps to LONG ValueType`() { + val entry = localEntry(key = "flag", type = "Long", defaultValue = "0") + val descriptor = entry.toFlagDescriptor() + assertEquals(ValueType.LONG, descriptor.valueType) + } + + @Test + fun `Float type maps to FLOAT ValueType`() { + val entry = localEntry(key = "flag", type = "Float", defaultValue = "1.5") + val descriptor = entry.toFlagDescriptor() + assertEquals(ValueType.FLOAT, descriptor.valueType) + } + + @Test + fun `Double type maps to DOUBLE ValueType`() { + val entry = localEntry(key = "flag", type = "Double", defaultValue = "3.14") + val descriptor = entry.toFlagDescriptor() + assertEquals(ValueType.DOUBLE, descriptor.valueType) + } + + @Test + fun `String type maps to STRING ValueType`() { + val entry = localEntry(key = "flag", type = "String", defaultValue = "\"hello\"") + val descriptor = entry.toFlagDescriptor() + assertEquals(ValueType.STRING, descriptor.valueType) + } + + // ── FlagKind mapping ─────────────────────────────────────────────────────── + + @Test + fun `local flagType maps to LOCAL FlagKind`() { + val entry = localEntry(key = "flag", type = "Boolean", defaultValue = "false", flagType = LocalFlagEntry.FLAG_TYPE_LOCAL) + assertEquals(FlagKind.LOCAL, entry.toFlagDescriptor().kind) + } + + @Test + fun `remote flagType maps to REMOTE FlagKind`() { + val entry = localEntry(key = "flag", type = "Boolean", defaultValue = "false", flagType = LocalFlagEntry.FLAG_TYPE_REMOTE) + assertEquals(FlagKind.REMOTE, entry.toFlagDescriptor().kind) + } + + // ── String default value unwrapping ──────────────────────────────────────── + + @Test + fun `String defaultValue with surrounding quotes is unwrapped`() { + val entry = localEntry(key = "greeting", type = "String", defaultValue = "\"hello\"") + val descriptor = entry.toFlagDescriptor() + assertEquals("hello", descriptor.defaultValue) + } + + @Test + fun `String defaultValue without surrounding quotes is kept as-is`() { + // ScanResultParser stores the raw value; this tests what happens with bare strings. + val entry = localEntry(key = "greeting", type = "String", defaultValue = "hello") + val descriptor = entry.toFlagDescriptor() + // removeSurrounding("\"") does nothing when the value does not start and end with " + assertEquals("hello", descriptor.defaultValue) + } + + // ── Enum mapping ────────────────────────────────────────────────────────── + + @Test + fun `enum entry maps to ENUM ValueType with enumTypeFqn and stripped constant name`() { + val entry = + LocalFlagEntry( + key = "checkout_variant", + defaultValue = "com.example.CheckoutVariant.FAST", + type = "com.example.CheckoutVariant", + moduleName = ":app", + propertyName = "checkoutVariant", + flagType = LocalFlagEntry.FLAG_TYPE_LOCAL, + ) + assertTrue(entry.isEnum, "Expected isEnum = true for FQN type") + val descriptor = entry.toFlagDescriptor() + assertEquals(ValueType.ENUM, descriptor.valueType) + assertEquals("com.example.CheckoutVariant", descriptor.enumTypeFqn) + // Only the constant name — not the FQN — is stored in defaultValue. + assertEquals("FAST", descriptor.defaultValue) + } + + @Test + fun `enum entry does not strip enumTypeFqn when isEnum is false`() { + // isEnum is computed as '.' in type — a type without dots is not an enum. + val entry = localEntry(key = "flag", type = "Boolean", defaultValue = "false") + assertNull(entry.toFlagDescriptor().enumTypeFqn) + } + + // ── Unknown type error ───────────────────────────────────────────────────── + + @Test + fun `unknown type throws IllegalStateException containing type and key`() { + val entry = localEntry(key = "my_date_flag", type = "Date", defaultValue = "2026-01-01") + val ex = assertFailsWith { entry.toFlagDescriptor() } + assertTrue(ex.message?.contains("Date") == true, "Error message must contain the type 'Date', got: ${ex.message}") + assertTrue(ex.message?.contains("my_date_flag") == true, "Error message must contain the key 'my_date_flag', got: ${ex.message}") + } + + // ── Optional metadata fields ─────────────────────────────────────────────── + + @Test + fun `null optional fields are passed through as null`() { + val entry = + LocalFlagEntry( + key = "flag", + defaultValue = "false", + type = "Boolean", + moduleName = ":app", + propertyName = "flag", + description = null, + category = null, + expiresAt = null, + ) + val descriptor = entry.toFlagDescriptor() + assertNull(descriptor.description) + assertNull(descriptor.category) + assertNull(descriptor.expiresAt) + } + + @Test + fun `non-null optional fields are preserved in FlagDescriptor`() { + val entry = + LocalFlagEntry( + key = "flag", + defaultValue = "false", + type = "Boolean", + moduleName = ":app", + propertyName = "flag", + description = "A useful flag", + category = "UI", + expiresAt = "2027-01-01", + ) + val descriptor = entry.toFlagDescriptor() + assertEquals("A useful flag", descriptor.description) + assertEquals("UI", descriptor.category) + assertEquals("2027-01-01", descriptor.expiresAt) + } + + // ── Non-ASCII key ────────────────────────────────────────────────────────── + + @Test + fun `non-ASCII key is passed through to FlagDescriptor unchanged`() { + // toCamelCase() splits on '_' and uppercases each word's first char. + // For "тёмная_тема": ["тёмная", "тема"] → "тёмная" + "Тема" = "тёмнаяТема" + val entry = + LocalFlagEntry( + key = "тёмная_тема", + defaultValue = "false", + type = "Boolean", + moduleName = ":app", + propertyName = + "тёмная_тема" + .split("_") + .mapIndexed { i, w -> + if (i == 0) w.lowercase() else w.replaceFirstChar { it.uppercase() } + }.joinToString(""), + ) + val descriptor = entry.toFlagDescriptor() + assertEquals("тёмная_тема", descriptor.key) + // propertyName is passed through as-is from the entry. + assertEquals("тёмнаяТема", descriptor.propertyName) + } + + // ── Pipe separator in String default value ───────────────────────────────── + + @Test + fun `pipe character in String default value is a known parser limitation`() { + // NOTE: ScanResultParser splits lines by '|' — strings whose value contains '|' break + // the pipe-delimited format and inflate the field count past the supported sizes + // (4 / 6 / 7 / 9). Lines that do not match a known field count are silently dropped + // (parseLine returns null). + // + // FlagContainer.string() wraps the default in escaped quotes when serialising, so the + // raw line for `string("my_flag", default = "a|b")` looks like: + // my_flag|"a|b"|String|:app|myFlag|local||| + // which splits into 10 parts instead of the expected 9 — the parser silently drops it. + // + // This test documents the limitation; a future minor PR may add `require('|' !in default)` + // to FlagContainer.string() to fail fast at configuration time instead of silently. + val rawLine = "my_flag|\"a|b\"|String|:app|myFlag|local|||" + val parts = rawLine.split("|") + assertEquals( + 10, + parts.size, + "A '|' inside defaultValue inflates the field count past 9; parser will return null and silently drop the entry", + ) + } + + // ── Same key in local and remote ─────────────────────────────────────────── + + @Test + fun `same key for local and remote entries produces distinct FlagDescriptors with different kinds`() { + // Conflict detection (which entry wins, deduplication) is handled in PR B. + // The mapper itself produces two FlagDescriptors and does not deduplicate. + val local = localEntry(key = "promo", type = "Boolean", defaultValue = "false", flagType = LocalFlagEntry.FLAG_TYPE_LOCAL) + val remote = localEntry(key = "promo", type = "Boolean", defaultValue = "false", flagType = LocalFlagEntry.FLAG_TYPE_REMOTE) + + val localDescriptor = local.toFlagDescriptor() + val remoteDescriptor = remote.toFlagDescriptor() + + assertEquals("promo", localDescriptor.key) + assertEquals("promo", remoteDescriptor.key) + assertEquals(FlagKind.LOCAL, localDescriptor.kind) + assertEquals(FlagKind.REMOTE, remoteDescriptor.kind) + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private fun localEntry( + key: String, + type: String, + defaultValue: String, + flagType: String = LocalFlagEntry.FLAG_TYPE_LOCAL, + propertyName: String = key, + ): LocalFlagEntry = + LocalFlagEntry( + key = key, + defaultValue = defaultValue, + type = type, + moduleName = ":app", + propertyName = propertyName, + flagType = flagType, + ) +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestSerializationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestSerializationTest.kt new file mode 100644 index 0000000..3aef5b4 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestSerializationTest.kt @@ -0,0 +1,252 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import kotlinx.serialization.SerializationException +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FeaturedManifestSerializationTest { + @Test + fun `round-trip produces identical object`() { + val manifest = + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature:checkout", + flags = + listOf( + FlagDescriptor( + key = "dark_mode", + propertyName = "darkMode", + kind = FlagKind.LOCAL, + valueType = ValueType.BOOLEAN, + defaultValue = "false", + ), + ), + ) + + val json = FeaturedManifestJson.encodeToString(manifest) + val decoded = FeaturedManifestJson.decodeFromString(json) + + assertEquals(manifest, decoded) + } + + @Test + fun `schemaVersion is present explicitly in JSON output`() { + val manifest = + FeaturedManifest( + schemaVersion = 1, + modulePath = ":", + flags = emptyList(), + ) + + val json = FeaturedManifestJson.encodeToString(manifest) + + assertTrue(json.contains("\"schemaVersion\""), "Expected 'schemaVersion' field in JSON") + assertTrue(json.contains("\"schemaVersion\": 1"), "Expected schemaVersion value 1 in JSON") + } + + @Test + fun `empty flags list serializes as empty array not omitted`() { + val manifest = + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":", + flags = emptyList(), + ) + + val json = FeaturedManifestJson.encodeToString(manifest) + + // Must appear as "flags": [] not be absent + assertTrue(json.contains("\"flags\": []"), "Expected 'flags': [] in JSON, got:\n$json") + } + + @Test + fun `null optional fields are omitted from JSON`() { + val manifest = + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":app", + flags = + listOf( + FlagDescriptor( + key = "feature", + propertyName = "feature", + kind = FlagKind.REMOTE, + valueType = ValueType.BOOLEAN, + defaultValue = "true", + enumTypeFqn = null, + description = null, + category = null, + expiresAt = null, + ), + ), + ) + + val json = FeaturedManifestJson.encodeToString(manifest) + + assertFalse(json.contains("enumTypeFqn"), "Null enumTypeFqn must be omitted from JSON") + assertFalse(json.contains("description"), "Null description must be omitted from JSON") + assertFalse(json.contains("category"), "Null category must be omitted from JSON") + assertFalse(json.contains("expiresAt"), "Null expiresAt must be omitted from JSON") + } + + @Test + fun `enum flag round-trip preserves enumTypeFqn`() { + val manifest = + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature:checkout", + flags = + listOf( + FlagDescriptor( + key = "checkout_variant", + propertyName = "checkoutVariant", + kind = FlagKind.LOCAL, + valueType = ValueType.ENUM, + defaultValue = "LEGACY", + enumTypeFqn = "com.example.CheckoutVariant", + ), + ), + ) + + val json = FeaturedManifestJson.encodeToString(manifest) + val decoded = FeaturedManifestJson.decodeFromString(json) + + assertEquals("com.example.CheckoutVariant", decoded.flags.first().enumTypeFqn) + assertEquals("LEGACY", decoded.flags.first().defaultValue) + } + + @Test + fun `Float and Double valueTypes round-trip correctly`() { + val flags = + listOf( + FlagDescriptor( + key = "float_flag", + propertyName = "floatFlag", + kind = FlagKind.LOCAL, + valueType = ValueType.FLOAT, + defaultValue = "1.5", + ), + FlagDescriptor( + key = "double_flag", + propertyName = "doubleFlag", + kind = FlagKind.REMOTE, + valueType = ValueType.DOUBLE, + defaultValue = "3.14", + ), + ) + val manifest = FeaturedManifest(schemaVersion = SCHEMA_VERSION, modulePath = ":", flags = flags) + + val json = FeaturedManifestJson.encodeToString(manifest) + val decoded = FeaturedManifestJson.decodeFromString(json) + + assertEquals(ValueType.FLOAT, decoded.flags[0].valueType) + assertEquals(ValueType.DOUBLE, decoded.flags[1].valueType) + } + + @Test + fun `unknown JSON field during decode does not throw (forward-compatible)`() { + val json = + """ + { + "schemaVersion": 1, + "modulePath": ":", + "flags": [], + "unknownFutureField": "some value" + } + """.trimIndent() + + // Should not throw — ignoreUnknownKeys = true in FeaturedManifestJson + val manifest = FeaturedManifestJson.decodeFromString(json) + assertEquals(1, manifest.schemaVersion) + assertEquals(":", manifest.modulePath) + } + + @Test + fun `unknown enum variant in FlagKind throws SerializationException`() { + val json = + """ + { + "schemaVersion": 1, + "modulePath": ":", + "flags": [ + { + "key": "f", + "propertyName": "f", + "kind": "UNKNOWN_KIND", + "valueType": "BOOLEAN", + "defaultValue": "false" + } + ] + } + """.trimIndent() + + // Silent skip of unknown enum variants would be a silent data-loss bug. + // SerializationException is the expected behavior — fail fast. + assertFailsWith { + FeaturedManifestJson.decodeFromString(json) + } + } + + @Test + fun `unknown enum variant in ValueType throws SerializationException`() { + val json = + """ + { + "schemaVersion": 1, + "modulePath": ":", + "flags": [ + { + "key": "f", + "propertyName": "f", + "kind": "LOCAL", + "valueType": "UNKNOWN_TYPE", + "defaultValue": "false" + } + ] + } + """.trimIndent() + + assertFailsWith { + FeaturedManifestJson.decodeFromString(json) + } + } + + @Test + fun `all flag fields are preserved in round-trip`() { + val flag = + FlagDescriptor( + key = "my_flag", + propertyName = "myFlag", + kind = FlagKind.REMOTE, + valueType = ValueType.STRING, + defaultValue = "hello world", + enumTypeFqn = null, + description = "A test flag", + category = "test", + expiresAt = "2026-12-31", + ) + val manifest = FeaturedManifest(schemaVersion = SCHEMA_VERSION, modulePath = ":app", flags = listOf(flag)) + + val decoded = + FeaturedManifestJson.decodeFromString( + FeaturedManifestJson.encodeToString(manifest), + ) + + val decodedFlag = decoded.flags.first() + assertEquals(flag.key, decodedFlag.key) + assertEquals(flag.propertyName, decodedFlag.propertyName) + assertEquals(flag.kind, decodedFlag.kind) + assertEquals(flag.valueType, decodedFlag.valueType) + assertEquals(flag.defaultValue, decodedFlag.defaultValue) + assertNull(decodedFlag.enumTypeFqn) + assertEquals("A test flag", decodedFlag.description) + assertEquals("test", decodedFlag.category) + assertEquals("2026-12-31", decodedFlag.expiresAt) + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTaskRegistrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTaskRegistrationTest.kt new file mode 100644 index 0000000..cd56b1b --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTaskRegistrationTest.kt @@ -0,0 +1,103 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import dev.androidbroadcast.featured.gradle.RESOLVE_FLAGS_TASK_NAME +import org.gradle.testfixtures.ProjectBuilder +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class GenerateFeaturedManifestTaskRegistrationTest { + @Test + fun `plugin registers generateFeaturedManifest task`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + assertTrue( + project.tasks.names.contains(GENERATE_FEATURED_MANIFEST_TASK_NAME), + "Expected '$GENERATE_FEATURED_MANIFEST_TASK_NAME' task to be registered by the plugin", + ) + } + + @Test + fun `generateFeaturedManifest task is of correct type`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val task = project.tasks.findByName(GENERATE_FEATURED_MANIFEST_TASK_NAME) + assertNotNull(task) + assertTrue( + task is GenerateFeaturedManifestTask, + "Expected task type GenerateFeaturedManifestTask but was ${task::class.simpleName}", + ) + } + + @Test + fun `generateFeaturedManifest task is in featured group`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val task = project.tasks.findByName(GENERATE_FEATURED_MANIFEST_TASK_NAME) + assertNotNull(task) + assertEquals( + "featured", + task.group, + "Expected task group 'featured' but was '${task.group}'", + ) + } + + @Test + fun `generateFeaturedManifest task output path follows convention`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val task = project.tasks.findByName(GENERATE_FEATURED_MANIFEST_TASK_NAME) as? GenerateFeaturedManifestTask + assertNotNull(task) + val outputPath = + task.outputFile + .get() + .asFile.path + assertTrue( + outputPath.endsWith("featured/featured-manifest.json"), + "Expected outputFile path to end with 'featured/featured-manifest.json', got: $outputPath", + ) + } + + @Test + fun `generate fails with IllegalArgumentException when modulePath does not start with colon`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val emptyFlags = File.createTempFile("flags", ".txt").apply { deleteOnExit() } + val task = project.tasks.findByName(GENERATE_FEATURED_MANIFEST_TASK_NAME) as GenerateFeaturedManifestTask + task.modulePath.set("not-a-gradle-path") + task.flagsFile.set(emptyFlags) + + val ex = assertFailsWith { task.generate() } + assertTrue( + ex.message?.contains("not-a-gradle-path") == true, + "Expected error message to name the offending path, got: ${ex.message}", + ) + assertTrue( + ex.message?.contains(":") == true, + "Expected error message to mention the required ':' prefix, got: ${ex.message}", + ) + } + + @Test + fun `generateFeaturedManifest task depends on resolveFeatureFlags`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val manifestTask = project.tasks.findByName(GENERATE_FEATURED_MANIFEST_TASK_NAME) + assertNotNull(manifestTask) + val resolveTask = project.tasks.findByName(RESOLVE_FLAGS_TASK_NAME) + assertNotNull(resolveTask) + assertTrue( + manifestTask.taskDependencies.getDependencies(manifestTask).contains(resolveTask), + "Expected '$GENERATE_FEATURED_MANIFEST_TASK_NAME' to depend on '$RESOLVE_FLAGS_TASK_NAME'", + ) + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/TestFixtureSupport.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/TestFixtureSupport.kt new file mode 100644 index 0000000..17fc588 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/TestFixtureSupport.kt @@ -0,0 +1,50 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import java.io.File + +// Shared helpers for the manifest test suite. Each integration / TestKit test copies a +// pinned fixture directory into a per-test temp folder so that test runs do not pollute +// the source tree and remain isolated from each other. + +/** + * Copies the fixture directory named [fixtureName] from `featured-gradle-plugin/src/test/fixtures/` + * into [dest]. Non-file entries are skipped. The `.gitkeep` marker files used to keep otherwise + * empty fixture directories in git are filtered out — they are not part of the project under test. + */ +internal fun copyManifestFixture( + fixtureName: String, + dest: File, +) { + val source = fixtureSourceDir(fixtureName) + source + .walkTopDown() + .filter { it.isFile && it.name != ".gitkeep" } + .forEach { file -> + val target = dest.resolve(file.relativeTo(source)) + target.parentFile?.mkdirs() + file.copyTo(target, overwrite = true) + } +} + +private fun fixtureSourceDir(fixtureName: String): File { + val moduleDir = File(System.getProperty("user.dir")) + val candidate = moduleDir.resolve("src/test/fixtures/$fixtureName") + require(candidate.isDirectory) { + "Fixture directory not found at ${candidate.absolutePath}. " + + "Expected it relative to module project dir: ${moduleDir.absolutePath}" + } + return candidate +} + +/** + * Returns the Android SDK directory from `ANDROID_HOME` or `ANDROID_SDK_ROOT`, or null when + * neither is set or the path is not a directory. Used by integration tests that need an + * Android SDK to run the Android Gradle plugin; without it they skip via JUnit `Assume`. + */ +internal fun androidSdkDirOrNull(): File? { + val path = + System.getenv("ANDROID_HOME")?.takeIf { it.isNotBlank() } + ?: System.getenv("ANDROID_SDK_ROOT")?.takeIf { it.isNotBlank() } + ?: return null + return File(path).takeIf { it.isDirectory } +} diff --git a/featured-registry/CLAUDE.md b/featured-registry/CLAUDE.md deleted file mode 100644 index 571191f..0000000 --- a/featured-registry/CLAUDE.md +++ /dev/null @@ -1,9 +0,0 @@ -# featured-registry - -See [README.md](README.md) for usage. - -## Commands - -```bash -./gradlew :featured-registry:allTests -``` diff --git a/featured-registry/README.md b/featured-registry/README.md deleted file mode 100644 index cce2027..0000000 --- a/featured-registry/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# featured-registry - -Runtime registry that tracks all `ConfigValue` instances across all modules in the app. - -Used by `featured-debug-ui` to enumerate every flag without manual registration. - -## How it works - -The `featured-gradle-plugin` task `generateFlagRegistrar` generates a `FlagRegistrar` class per module at build time. Each registrar registers its module's params into `FlagRegistry` at app startup. - -## Usage - -Add as `debugImplementation` — not needed in release builds. - -```kotlin -debugImplementation("dev.androidbroadcast.featured:featured-registry") -``` - -Pair with `featured-debug-ui`. No manual setup required beyond applying the Gradle plugin. diff --git a/featured-registry/build.gradle.kts b/featured-registry/build.gradle.kts deleted file mode 100644 index 559c71a..0000000 --- a/featured-registry/build.gradle.kts +++ /dev/null @@ -1,143 +0,0 @@ -import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - alias(libs.plugins.kotlinMultiplatform) - alias(libs.plugins.androidKmpLibrary) - alias(libs.plugins.kover) - alias(libs.plugins.mavenPublish) - alias(libs.plugins.dokka) -} - -kotlin { - explicitApi() - jvmToolchain(21) - - compilerOptions { - freeCompilerArgs.add("-Xexpect-actual-classes") - } - - android { - namespace = "dev.androidbroadcast.featured.registry" - compileSdk = - libs.versions.android.compileSdk - .get() - .toInt() - minSdk = - libs.versions.android.minSdk - .get() - .toInt() - compilerOptions { - jvmTarget.set(JvmTarget.JVM_21) - } - } - - listOf( - iosX64(), - iosArm64(), - iosSimulatorArm64(), - ).forEach { iosTarget -> - iosTarget.binaries.framework { - baseName = "FeaturedRegistry" - isStatic = true - } - } - - jvm() - - @OptIn(ExperimentalKotlinGradlePluginApi::class) - applyDefaultHierarchyTemplate { - common { - group("jvmCommon") { - withJvm() - withCompilations { it.target.name == "android" } - } - group("native") { - withIosX64() - withIosArm64() - withIosSimulatorArm64() - } - } - } - - sourceSets { - commonMain.dependencies { - api(projects.core) - } - - @Suppress("unused") - val commonTest by getting { - dependencies { - implementation(libs.kotlin.test) - } - } - } -} - -mavenPublishing { - publishToMavenCentral() - signAllPublications() - coordinates( - groupId = "dev.androidbroadcast.featured", - artifactId = "featured-registry", - ) - pom { - name.set("Featured Registry") - description.set("Registry module for Featured – runtime flag registration and lookup for KMP") - inceptionYear.set("2024") - url.set("https://github.com/AndroidBroadcast/Featured") - licenses { - license { - name.set("MIT License") - url.set("https://opensource.org/licenses/MIT") - distribution.set("repo") - } - } - developers { - developer { - id.set("androidbroadcast") - name.set("Kirill Rozov") - url.set("https://github.com/androidbroadcast") - } - } - scm { - url.set("https://github.com/AndroidBroadcast/Featured") - connection.set("scm:git:git://github.com/AndroidBroadcast/Featured.git") - developerConnection.set("scm:git:ssh://git@github.com/AndroidBroadcast/Featured.git") - } - } -} - -kover { - reports { - filters { - excludes { - classes("*Test*", "*Mock*", "*Fake*") - } - } - - total { - html { - onCheck = false - } - - xml { - onCheck = false - } - - log { - onCheck = true - header = "Code coverage summary for :featured-registry module" - format = "Line coverage: %" - } - - verify { - onCheck = true - - rule { - minBound(85) - } - } - } - } -} diff --git a/featured-registry/src/commonMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistry.kt b/featured-registry/src/commonMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistry.kt deleted file mode 100644 index 4045a8e..0000000 --- a/featured-registry/src/commonMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistry.kt +++ /dev/null @@ -1,34 +0,0 @@ -package dev.androidbroadcast.featured.registry - -import dev.androidbroadcast.featured.ConfigParam - -/** - * Central registry that collects all [ConfigParam] instances across feature modules. - * Powers debug UI auto-discovery of available feature flags. - * - * Thread-safe: registration and retrieval use platform-specific synchronization - * (a lock on JVM/Android, CAS-based updates on Native/iOS). - */ -public object FlagRegistry { - private val delegate = FlagRegistryDelegate() - - /** - * Registers a [ConfigParam] with the registry. - * Duplicate registrations (same param by key equality) are silently ignored. - */ - public fun register(param: ConfigParam<*>) { - delegate.register(param) - } - - /** - * Returns an immutable snapshot of all currently registered [ConfigParam] instances. - */ - public fun all(): List> = delegate.all() - - /** - * Clears all registered params. Intended for use in tests only. - */ - internal fun reset() { - delegate.reset() - } -} diff --git a/featured-registry/src/commonMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryDelegate.kt b/featured-registry/src/commonMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryDelegate.kt deleted file mode 100644 index c76fdaf..0000000 --- a/featured-registry/src/commonMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryDelegate.kt +++ /dev/null @@ -1,11 +0,0 @@ -package dev.androidbroadcast.featured.registry - -import dev.androidbroadcast.featured.ConfigParam - -internal expect class FlagRegistryDelegate() { - fun register(param: ConfigParam<*>) - - fun all(): List> - - fun reset() -} diff --git a/featured-registry/src/commonTest/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryTest.kt b/featured-registry/src/commonTest/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryTest.kt deleted file mode 100644 index abf5477..0000000 --- a/featured-registry/src/commonTest/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -package dev.androidbroadcast.featured.registry - -import dev.androidbroadcast.featured.ConfigParam -import kotlin.test.BeforeTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class FlagRegistryTest { - @BeforeTest - fun setUp() { - FlagRegistry.reset() - } - - @Test - fun registeredParamAppearsInAll() { - val param = ConfigParam(key = "flag_a", defaultValue = true) - FlagRegistry.register(param) - assertTrue(FlagRegistry.all().contains(param)) - } - - @Test - fun allReturnsAllRegisteredParams() { - val p1 = ConfigParam(key = "flag_b", defaultValue = false) - val p2 = ConfigParam(key = "flag_c", defaultValue = 42) - FlagRegistry.register(p1) - FlagRegistry.register(p2) - val all = FlagRegistry.all() - assertEquals(2, all.size) - assertTrue(all.contains(p1)) - assertTrue(all.contains(p2)) - } - - @Test - fun registeringDuplicateKeyDoesNotDuplicateEntry() { - val param = ConfigParam(key = "flag_d", defaultValue = "hello") - FlagRegistry.register(param) - FlagRegistry.register(param) - assertEquals(1, FlagRegistry.all().size) - } - - @Test - fun allReturnsEmptyWhenNothingRegistered() { - assertTrue(FlagRegistry.all().isEmpty()) - } - - @Test - fun registeringSameKeyWithDifferentDefaultValueDoesNotDuplicate() { - val param1 = ConfigParam(key = "flag_g", defaultValue = "first") - val param2 = ConfigParam(key = "flag_g", defaultValue = "second") - FlagRegistry.register(param1) - FlagRegistry.register(param2) - assertEquals(1, FlagRegistry.all().size) - } - - @Test - fun allReturnsImmutableSnapshot() { - val param = ConfigParam(key = "flag_e", defaultValue = 1) - FlagRegistry.register(param) - val snapshot = FlagRegistry.all() - FlagRegistry.register(ConfigParam(key = "flag_f", defaultValue = 2)) - assertEquals(1, snapshot.size) - } -} diff --git a/featured-registry/src/iosMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryDelegate.kt b/featured-registry/src/iosMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryDelegate.kt deleted file mode 100644 index d53a8eb..0000000 --- a/featured-registry/src/iosMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryDelegate.kt +++ /dev/null @@ -1,26 +0,0 @@ -package dev.androidbroadcast.featured.registry - -import dev.androidbroadcast.featured.ConfigParam -import kotlin.concurrent.AtomicReference - -internal actual class FlagRegistryDelegate actual constructor() { - // AtomicReference provides safe publication on Kotlin/Native new memory model. - // Copy-on-write: every register() replaces the list atomically via CAS. - private val paramsRef = AtomicReference>>(emptyList()) - - actual fun register(param: ConfigParam<*>) { - // Spin-loop CAS: add param if no entry with the same key exists. - while (true) { - val current = paramsRef.value - if (current.any { it.key == param.key }) return - val next = current + param - if (paramsRef.compareAndSet(current, next)) return - } - } - - actual fun all(): List> = paramsRef.value.toList() - - actual fun reset() { - paramsRef.value = emptyList() - } -} diff --git a/featured-registry/src/jvmCommonMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryDelegate.kt b/featured-registry/src/jvmCommonMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryDelegate.kt deleted file mode 100644 index b738de3..0000000 --- a/featured-registry/src/jvmCommonMain/kotlin/dev/androidbroadcast/featured/registry/FlagRegistryDelegate.kt +++ /dev/null @@ -1,22 +0,0 @@ -package dev.androidbroadcast.featured.registry - -import dev.androidbroadcast.featured.ConfigParam -import java.util.concurrent.locks.ReentrantLock -import kotlin.concurrent.withLock - -internal actual class FlagRegistryDelegate actual constructor() { - private val lock = ReentrantLock() - - // Keyed by ConfigParam.key to guarantee one entry per key across platforms. - private val params: LinkedHashMap> = LinkedHashMap() - - actual fun register(param: ConfigParam<*>) { - lock.withLock { params.putIfAbsent(param.key, param) } - } - - actual fun all(): List> = lock.withLock { params.values.toList() } - - actual fun reset() { - lock.withLock { params.clear() } - } -} diff --git a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/assertions/JarAssertions.kt b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/assertions/JarAssertions.kt index 9148399..bd633f4 100644 --- a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/assertions/JarAssertions.kt +++ b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/assertions/JarAssertions.kt @@ -1,7 +1,13 @@ package dev.androidbroadcast.featured.shrinker.assertions +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.FieldVisitor +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes import java.io.File import java.util.jar.JarFile +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -28,3 +34,86 @@ internal fun assertClassPresent( ) } } + +/** + * Asserts that the bytecode of [ownerInternalName] in [jar] contains no reference to + * [referencedInternalName] — neither as a `NEW`/type reference nor as a method-call target. + * + * This proves that R8 actually constant-folded the flag and removed the dead branch's call + * site from the *caller*, as opposed to merely keeping the branch-target class alive. A test + * that only checked class presence would still pass if folding silently stopped working and + * the call site stayed reachable; this assertion closes that gap. + */ +internal fun assertClassDoesNotReference( + jar: File, + ownerInternalName: String, + referencedInternalName: String, +) { + val classBytes = + JarFile(jar).use { jf -> + val entry = + assertNotNull( + jf.getJarEntry("$ownerInternalName.class"), + "Expected $ownerInternalName to be present in the output JAR", + ) + jf.getInputStream(entry).use { it.readBytes() } + } + + var referenced = false + val referencedType = "L$referencedInternalName;" + val detector = + object : ClassVisitor(Opcodes.ASM9) { + override fun visitMethod( + access: Int, + name: String?, + descriptor: String?, + signature: String?, + exceptions: Array?, + ): MethodVisitor = + object : MethodVisitor(Opcodes.ASM9) { + override fun visitTypeInsn( + opcode: Int, + type: String?, + ) { + if (type == referencedInternalName) referenced = true + } + + override fun visitMethodInsn( + opcode: Int, + owner: String?, + name: String?, + descriptor: String?, + isInterface: Boolean, + ) { + if (owner == referencedInternalName) referenced = true + } + + override fun visitFieldInsn( + opcode: Int, + owner: String?, + name: String?, + descriptor: String?, + ) { + if (owner == referencedInternalName) referenced = true + } + } + + override fun visitField( + access: Int, + name: String?, + descriptor: String?, + signature: String?, + value: Any?, + ): FieldVisitor? { + if (descriptor == referencedType) referenced = true + return null + } + } + ClassReader(classBytes).accept(detector, ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) + + assertFalse( + referenced, + "Expected $ownerInternalName to no longer reference $referencedInternalName after R8 " + + "folded the disabled branch, but a reference was found in its bytecode", + ) +} diff --git a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/bytecode/SyntheticBytecodeFactory.kt b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/bytecode/SyntheticBytecodeFactory.kt index 71a5674..7c4d1d4 100644 --- a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/bytecode/SyntheticBytecodeFactory.kt +++ b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/bytecode/SyntheticBytecodeFactory.kt @@ -34,10 +34,11 @@ internal const val IF_BRANCH_CODE_INTERNAL = "IfBranchCode" internal const val ELSE_BRANCH_CODE_INTERNAL = "ElseBranchCode" internal const val BIFURCATED_CALLER_INTERNAL = "BifurcatedCaller" -// Derived from ExtensionFunctionGenerator.jvmFileName(":test"): -// ":test".removePrefix(":") = "test" → capitalize → "Test" → "FeaturedTest_FlagExtensionsKt" +// Derived from ExtensionFunctionGenerator.fileName(":test"): +// modulePathToFileSuffix(":test") = "Test" → "GeneratedFlagExtensionsTest.kt" +// → JVM class (Kotlin file-to-class convention): "GeneratedFlagExtensionsTestKt" internal const val BOOL_EXTENSIONS_INTERNAL = - "dev/androidbroadcast/featured/generated/FeaturedTest_FlagExtensionsKt" + "dev/androidbroadcast/featured/generated/GeneratedFlagExtensionsTestKt" internal const val IS_DARK_MODE_ENABLED = "isDarkModeEnabled" @@ -46,10 +47,11 @@ internal const val INT_CONFIG_VALUES_INTERNAL = "dev/androidbroadcast/featured/I internal const val POSITIVE_COUNT_CODE_INTERNAL = "PositiveCountCode" internal const val INT_CALLER_INTERNAL = "IntCaller" -// Derived from ExtensionFunctionGenerator.jvmFileName(":int-test"): -// ":int-test".removePrefix(":") = "int-test" → capitalize first char → "Int-test" → "FeaturedInt-test_FlagExtensionsKt" +// Derived from ExtensionFunctionGenerator.fileName(":int-test"): +// modulePathToFileSuffix(":int-test") splits on "-" → "Int" + "Test" = "IntTest" +// → "GeneratedFlagExtensionsIntTest.kt" → JVM class: "GeneratedFlagExtensionsIntTestKt" internal const val INT_EXTENSIONS_INTERNAL = - "dev/androidbroadcast/featured/generated/FeaturedInt-test_FlagExtensionsKt" + "dev/androidbroadcast/featured/generated/GeneratedFlagExtensionsIntTestKt" internal const val GET_MAX_RETRIES = "getMaxRetries" diff --git a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/r8/R8BooleanFlagEliminationTest.kt b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/r8/R8BooleanFlagEliminationTest.kt index fda8aba..337a860 100644 --- a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/r8/R8BooleanFlagEliminationTest.kt +++ b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/r8/R8BooleanFlagEliminationTest.kt @@ -1,12 +1,14 @@ package dev.androidbroadcast.featured.shrinker.r8 import dev.androidbroadcast.featured.shrinker.assertions.assertClassAbsent +import dev.androidbroadcast.featured.shrinker.assertions.assertClassDoesNotReference import dev.androidbroadcast.featured.shrinker.assertions.assertClassPresent import dev.androidbroadcast.featured.shrinker.bytecode.BIFURCATED_CALLER_INTERNAL import dev.androidbroadcast.featured.shrinker.bytecode.ELSE_BRANCH_CODE_INTERNAL import dev.androidbroadcast.featured.shrinker.bytecode.IF_BRANCH_CODE_INTERNAL import dev.androidbroadcast.featured.shrinker.harness.R8TestHarness import dev.androidbroadcast.featured.shrinker.rules.writeBooleanRules +import dev.androidbroadcast.featured.shrinker.rules.writeBooleanRulesWithKeptDeadBranch import dev.androidbroadcast.featured.shrinker.rules.writeNoBooleanAssumeRules import kotlin.test.Test @@ -26,7 +28,7 @@ import kotlin.test.Test * class ConfigValues { boolean enabled; ConfigValues(boolean) } * * // Mirrors ExtensionFunctionGenerator output for module ":test" - * class FeaturedTest_FlagExtensionsKt { + * class GeneratedFlagExtensionsTestKt { * static boolean isDarkModeEnabled(ConfigValues cv) { return cv.enabled; } * } * @@ -41,7 +43,7 @@ import kotlin.test.Test * class BifurcatedCaller { * static void execute(boolean enabled) { * ConfigValues cv = new ConfigValues(enabled); - * if (FeaturedTest_FlagExtensionsKt.isDarkModeEnabled(cv)) { + * if (GeneratedFlagExtensionsTestKt.isDarkModeEnabled(cv)) { * new IfBranchCode().doWork(); * } else { * new ElseBranchCode().doWork(); @@ -94,4 +96,32 @@ internal class R8BooleanFlagEliminationTest : R8TestHarness() { assertClassPresent(outputJar, IF_BRANCH_CODE_INTERNAL) assertClassPresent(outputJar, ELSE_BRANCH_CODE_INTERNAL) } + + /** + * Regression guard for the consumer pitfall: a user-supplied `-keep` on a class that is + * only reachable through the disabled branch defeats dead-code elimination. + * + * The `-assumevalues … return false` rule is present and still works — R8 constant-folds + * the flag and drops the dead branch's call site, so runtime behaviour is unchanged. But + * `-keep class IfBranchCode { *; }` is an unconditional GC root, so the class itself can no + * longer be tree-shaken: it survives in the output even though nothing reaches it. + * + * This is the failure mode behind broad wildcard / `@Keep` rules silently inflating the + * APK. The control test above proves elimination normally happens; this test proves a + * `-keep` is what brings the dead class back. + * + * The final assertion pins the documented split between the two R8 phases: even though the + * class is kept, the `-assumevalues` rule must still have folded the disabled branch, so + * `BifurcatedCaller` must no longer reference `IfBranchCode`. Without this, the test would + * pass even if folding silently stopped working and the call site stayed reachable. + */ + @Test + fun `dead-branch class survives when a user -keep rule pins it despite the assumevalues rule`() { + val outputJar = runBooleanR8 { writeBooleanRulesWithKeptDeadBranch(it) } + + assertClassPresent(outputJar, IF_BRANCH_CODE_INTERNAL) + assertClassPresent(outputJar, ELSE_BRANCH_CODE_INTERNAL) + assertClassPresent(outputJar, BIFURCATED_CALLER_INTERNAL) + assertClassDoesNotReference(outputJar, BIFURCATED_CALLER_INTERNAL, IF_BRANCH_CODE_INTERNAL) + } } diff --git a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/r8/R8IntFlagEliminationTest.kt b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/r8/R8IntFlagEliminationTest.kt index c786092..751a901 100644 --- a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/r8/R8IntFlagEliminationTest.kt +++ b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/r8/R8IntFlagEliminationTest.kt @@ -18,7 +18,7 @@ import kotlin.test.Test * ```java * class IntConfigValues { int count; IntConfigValues(int) } * - * class FeaturedIntTest_FlagExtensionsKt { + * class GeneratedFlagExtensionsIntTestKt { * static int getMaxRetries(IntConfigValues cv) { return cv.count; } * } * @@ -27,7 +27,7 @@ import kotlin.test.Test * class IntCaller { * static void execute(int count) { * IntConfigValues cv = new IntConfigValues(count); - * if (FeaturedIntTest_FlagExtensionsKt.getMaxRetries(cv) > 0) { + * if (GeneratedFlagExtensionsIntTestKt.getMaxRetries(cv) > 0) { * new PositiveCountCode().doWork(); * } * } diff --git a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/rules/ProguardRulesWriter.kt b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/rules/ProguardRulesWriter.kt index 69f409d..2bce2b0 100644 --- a/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/rules/ProguardRulesWriter.kt +++ b/featured-shrinker-tests/src/test/kotlin/dev/androidbroadcast/featured/shrinker/rules/ProguardRulesWriter.kt @@ -49,6 +49,32 @@ internal fun writeBooleanRules( ) } +/** + * Same `-assumevalues` block as [writeBooleanRules] with `returnValue = false`, but with an + * extra `-keep class … { *; }` on the **dead-branch** class ([IF_BRANCH_CODE_FQN]). + * + * This models a consumer that — deliberately or, far more commonly, via a broad wildcard or + * `@Keep` rule — pins a class that is only reachable through a disabled flag branch. + * + * The `-assumevalues` rule still lets R8 constant-fold the flag and drop the dead branch's + * *call site*, so behaviour is unchanged. But `-keep` is an unconditional GC root: the class + * itself can no longer be tree-shaken and survives in the output despite being unreachable, + * silently defeating the size benefit of build-time flags. + */ +internal fun writeBooleanRulesWithKeptDeadBranch(dest: File) { + dest.writeText( + """ + -assumevalues class $BOOL_EXTENSIONS_FQN { + boolean $IS_DARK_MODE_ENABLED($CONFIG_VALUES_FQN) return false; + } + -keep class $BIFURCATED_CALLER_FQN { *; } + -keep class $IF_BRANCH_CODE_FQN { *; } + -keepclassmembers class $ELSE_BRANCH_CODE_FQN { public static int sideEffect; } + -dontwarn ** + """.trimIndent(), + ) +} + /** * No `-assumevalues` block — R8 cannot constant-fold the flag value. * The `-keepclassmembers` rules ensure the `sideEffect` field is not stripped diff --git a/gradle.properties b/gradle.properties index c4166f5..8baa471 100644 --- a/gradle.properties +++ b/gradle.properties @@ -36,7 +36,7 @@ android.r8.strictInputValidation=true android.proguard.failOnMissingFiles=true # Publishing -VERSION_NAME=1.0.0-Beta1 +VERSION_NAME=1.0.0 # Dokka org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 03a5a0f..94e2a25 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ firebaseBom = "34.11.0" junit = "4.13.2" kotlin = "2.3.10" kotlinx-coroutines = "1.10.2" +kotlinx-serialization = "1.11.0" kover = "0.9.8" material = "1.13.0" mockk = "1.14.9" @@ -51,6 +52,7 @@ androidx-test-runner = { module = "androidx.test:runner", version.ref = "android androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } +androidx-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" } androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } @@ -58,6 +60,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-playServices = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinx-coroutines" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } firebase-analytics = { module = "com.google.firebase:firebase-analytics" } @@ -85,6 +88,7 @@ composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMul composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } skie = { id = "co.touchlab.skie", version.ref = "skie" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } 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 diff --git a/sample/CLAUDE.md b/sample/CLAUDE.md new file mode 100644 index 0000000..204e552 --- /dev/null +++ b/sample/CLAUDE.md @@ -0,0 +1,39 @@ +# Sample CLAUDE.md + +The sample is intentionally a multi-module demonstration of the Featured plugin family. + +## Module map + +- `:sample:feature-checkout` — owns `CheckoutVariant` enum + 2 local flags (`new_checkout` Boolean, `checkout_variant` enum). +- `:sample:feature-promotions` — 1 remote flag (`promo_banner_enabled` Boolean). +- `:sample:feature-ui` — 2 local UI flags (`main_button_red` Boolean, `new_feature_section_enabled` Boolean). +- `:sample:shared` — pure aggregator (`dev.androidbroadcast.featured.application`). Contains Compose UI (`FeaturedSample`, `SampleApp`); per-feature ViewModels (`CheckoutFlagsViewModel`, `PromotionsFlagsViewModel`, `UiFlagsViewModel`) live in their respective `:sample:feature-*` modules. No flag declarations of its own. +- `:sample:android-app` — Activity shell; wires `DataStoreConfigValueProvider` + `FeatureFlagsDebugScreen`. +- `:sample:desktop` — JVM shell; uses `InMemoryConfigValueProvider`. +- `iosApp/` — Xcode project consuming `FeaturedSampleApp.framework` (static, produced by `:sample:shared`). + +## Observe-bridge convention + +Each `:sample:feature-*` module ships `*FlagObservers.kt` with public `ConfigValues` extensions +(e.g. `mainButtonRedFlow()`, `setMainButtonRed()`). UI consumers should call these instead of +referencing `GeneratedLocalFlags*` / `GeneratedRemoteFlags*` directly. + +For non-reactive reads (logging, eager-conditional code paths) use `configValues.getValueCached(param)` +directly — the codegen-emitted `is*Enabled()` / `get*()` extensions are non-suspend and call this +under the hood. + +## Adding a flag + +1. Edit the feature module's `build.gradle.kts` — add a declaration inside `featured { localFlags { ... } }` or `featured { remoteFlags { ... } }`. +2. Add a public observer / setter in `*FlagObservers.kt`. +3. If the UI needs it, expose a `StateFlow` + setter in the feature's `*FlagsViewModel` (e.g. `CheckoutFlagsViewModel`). + +## Aggregation + +`:sample:shared` declares `featuredAggregation(project(":sample:feature-*"))` for all three modules and wires the `generateFeaturedRegistry` task output into `commonMain`. The resulting `GeneratedFeaturedRegistry.all` is passed to `FeatureFlagsDebugScreen`. + +## Multi-module wiring + +The sample constructs one `ConfigValues` per `:sample:feature-*` module — three per-feature instances on every platform. On Android the shell builds one additional `ConfigValues` (`debugConfigValues`) and passes it to `FeatureFlagsDebugScreen`; Desktop and iOS do not wire a debug-UI entry and omit that fourth instance. All instances share the same `LocalConfigValueProvider`, so overrides toggled through the debug screen propagate to every per-module instance via the shared provider's reactive `observe`. Each feature module's flag declarations are encapsulated behind its `internal` `GeneratedLocalFlagsX` object and exposed only via public observe-bridge extensions (`*FlagObservers.kt`) and a per-feature `ViewModel` that takes only its own `ConfigValues`. + +This is the canonical demonstration of the recommended pattern for real apps: a 20-module app wires 20 production `ConfigValues` plus, where a debug surface exists, one extra `ConfigValues` for the debug screen — all over a single DataStore. diff --git a/sample/android-app/build.gradle.kts b/sample/android-app/build.gradle.kts index dc3e224..d005a0a 100644 --- a/sample/android-app/build.gradle.kts +++ b/sample/android-app/build.gradle.kts @@ -48,6 +48,9 @@ dependencies { implementation(project(":sample:shared")) implementation(project(":featured-debug-ui")) implementation(project(":featured-platform")) + implementation(project(":providers:datastore")) implementation(libs.androidx.activity.compose) implementation(libs.androidx.appcompat) + // viewModel { } composable used in setContent to scope VMs to the Activity ViewModelStore. + implementation(libs.androidx.lifecycle.viewmodelCompose) } diff --git a/sample/android-app/src/main/kotlin/dev/androidbroadcast/featured/sample/MainActivity.kt b/sample/android-app/src/main/kotlin/dev/androidbroadcast/featured/sample/MainActivity.kt index e9f0813..a5e5f0d 100644 --- a/sample/android-app/src/main/kotlin/dev/androidbroadcast/featured/sample/MainActivity.kt +++ b/sample/android-app/src/main/kotlin/dev/androidbroadcast/featured/sample/MainActivity.kt @@ -9,31 +9,63 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.lifecycle.viewmodel.compose.viewModel import dev.androidbroadcast.featured.ConfigValues import dev.androidbroadcast.featured.SampleApp +import dev.androidbroadcast.featured.datastore.DataStoreConfigValueProvider +import dev.androidbroadcast.featured.datastore.registerConverter import dev.androidbroadcast.featured.debugui.FeatureFlagsDebugScreen +import dev.androidbroadcast.featured.enumConverter +import dev.androidbroadcast.featured.generated.GeneratedFeaturedRegistry import dev.androidbroadcast.featured.platform.defaultLocalProvider +import dev.androidbroadcast.featured.sample.checkout.CheckoutFlagsViewModel +import dev.androidbroadcast.featured.sample.checkout.CheckoutVariant +import dev.androidbroadcast.featured.sample.promotions.PromotionsFlagsViewModel +import dev.androidbroadcast.featured.sample.ui.UiFlagsViewModel class MainActivity : ComponentActivity() { - // ConfigValues is held at Activity scope for this sample. - // In production, move to Application or a DI singleton to avoid - // recreating (and re-opening) the DataStore file on every rotation. - private val configValues by lazy { - ConfigValues(localProvider = defaultLocalProvider(applicationContext)) + // A single LocalConfigValueProvider is shared across all ConfigValues instances so + // every module reads and writes the same underlying DataStore file. In production, + // move this to Application scope or a DI singleton to avoid reopening the file on rotation. + private val sharedLocalProvider by lazy { + val provider = defaultLocalProvider(applicationContext) + // DataStore only handles primitives natively; register a converter so that the + // enum-typed checkoutVariant flag can be persisted and observed without throwing. + (provider as? DataStoreConfigValueProvider) + ?.registerConverter(enumConverter()) + provider } + // Each feature module gets its own ConfigValues instance backed by the same provider. + // Per-module ConfigValues is the pattern Featured is designed around: flags are scoped + // to the module that declared them. + private val checkoutConfigValues by lazy { ConfigValues(localProvider = sharedLocalProvider) } + private val promotionsConfigValues by lazy { ConfigValues(localProvider = sharedLocalProvider) } + private val uiConfigValues by lazy { ConfigValues(localProvider = sharedLocalProvider) } + + // Aggregated ConfigValues for the debug screen — observes all flags across all modules. + private val debugConfigValues by lazy { ConfigValues(localProvider = sharedLocalProvider) } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { + // ViewModels are created here so they are scoped to the Activity's ViewModelStore + // and survive configuration changes. Each VM receives its module's ConfigValues. + val checkoutViewModel = viewModel(key = "checkout") { CheckoutFlagsViewModel(checkoutConfigValues) } + val promotionsViewModel = viewModel(key = "promotions") { PromotionsFlagsViewModel(promotionsConfigValues) } + val uiViewModel = viewModel(key = "ui") { UiFlagsViewModel(uiConfigValues) } + var showDebug by rememberSaveable { mutableStateOf(false) } if (showDebug) { BackHandler { showDebug = false } - FeatureFlagsDebugScreen(configValues = configValues) + FeatureFlagsDebugScreen(configValues = debugConfigValues, registry = GeneratedFeaturedRegistry.all) } else { SampleApp( - configValues = configValues, + uiViewModel = uiViewModel, + promotionsViewModel = promotionsViewModel, + checkoutViewModel = checkoutViewModel, onOpenDebugUi = { showDebug = true }, ) } diff --git a/sample/desktop/src/jvmMain/kotlin/dev/androidbroadcast/featured/Main.Desktop.kt b/sample/desktop/src/jvmMain/kotlin/dev/androidbroadcast/featured/Main.Desktop.kt index 0f1ad0d..7795739 100644 --- a/sample/desktop/src/jvmMain/kotlin/dev/androidbroadcast/featured/Main.Desktop.kt +++ b/sample/desktop/src/jvmMain/kotlin/dev/androidbroadcast/featured/Main.Desktop.kt @@ -4,17 +4,38 @@ package dev.androidbroadcast.featured import androidx.compose.ui.window.Window import androidx.compose.ui.window.application +import dev.androidbroadcast.featured.sample.checkout.CheckoutFlagsViewModel +import dev.androidbroadcast.featured.sample.promotions.PromotionsFlagsViewModel +import dev.androidbroadcast.featured.sample.ui.UiFlagsViewModel -// ConfigValues is constructed once at the application entry point and passed -// explicitly — the recommended pattern for multi-module apps using DI. +// Each feature module gets its own ConfigValues backed by the same in-memory provider. +// Per-module ConfigValues is the pattern Featured is designed around: flags are scoped +// to the module that declared them. fun main() { - val configValues = ConfigValues(localProvider = InMemoryConfigValueProvider()) + val sharedLocalProvider = InMemoryConfigValueProvider() + + val checkoutConfigValues = ConfigValues(localProvider = sharedLocalProvider) + val promotionsConfigValues = ConfigValues(localProvider = sharedLocalProvider) + val uiConfigValues = ConfigValues(localProvider = sharedLocalProvider) + // No debug aggregator on Desktop — the Compose Desktop shell does not wire a debug-UI + // entry. The Android shell builds a fourth `ConfigValues` for the debug screen. + + // VMs are constructed once here — the desktop application has a single-window lifetime + // with no configuration changes, so there is no need for a ViewModelStore. + val checkoutViewModel = CheckoutFlagsViewModel(checkoutConfigValues) + val promotionsViewModel = PromotionsFlagsViewModel(promotionsConfigValues) + val uiViewModel = UiFlagsViewModel(uiConfigValues) + application { Window( onCloseRequest = ::exitApplication, title = "Featured", ) { - SampleApp(configValues = configValues) + SampleApp( + uiViewModel = uiViewModel, + promotionsViewModel = promotionsViewModel, + checkoutViewModel = checkoutViewModel, + ) } } } diff --git a/sample/feature-checkout/build.gradle.kts b/sample/feature-checkout/build.gradle.kts new file mode 100644 index 0000000..4d4373b --- /dev/null +++ b/sample/feature-checkout/build.gradle.kts @@ -0,0 +1,62 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidKmpLibrary) + id("dev.androidbroadcast.featured") +} + +kotlin { + jvmToolchain(21) + + android { + namespace = "dev.androidbroadcast.featured.sample.checkout" + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() + minSdk = + libs.versions.android.minSdk + .get() + .toInt() + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + } + + iosX64() + iosArm64() + iosSimulatorArm64() + + jvm() + + sourceSets { + commonMain.dependencies { + api(project(":core")) + api(libs.kotlinx.coroutines.core) + // CheckoutFlagsViewModel is in this module's public API surface. + api(libs.androidx.lifecycle.viewmodel) + } + } + + sourceSets.commonMain.get().kotlin.srcDir( + tasks.named("generateConfigParam").map { it.outputs.files.singleFile }, + ) +} + +featured { + localFlags { + boolean("new_checkout", default = false) { + description = "Enable the redesigned checkout flow" + category = "checkout" + } + enum( + key = "checkout_variant", + typeFqn = "dev.androidbroadcast.featured.sample.checkout.CheckoutVariant", + default = "LEGACY", + ) { + description = "Controls which checkout flow variant is shown to the user" + category = "checkout" + } + } +} diff --git a/sample/feature-checkout/src/androidMain/AndroidManifest.xml b/sample/feature-checkout/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/sample/feature-checkout/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/sample/feature-checkout/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/checkout/CheckoutFlagObservers.kt b/sample/feature-checkout/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/checkout/CheckoutFlagObservers.kt new file mode 100644 index 0000000..5c92ebe --- /dev/null +++ b/sample/feature-checkout/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/checkout/CheckoutFlagObservers.kt @@ -0,0 +1,17 @@ +package dev.androidbroadcast.featured.sample.checkout + +import dev.androidbroadcast.featured.ConfigValues +import dev.androidbroadcast.featured.generated.GeneratedLocalFlagsSampleFeatureCheckout +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +public fun ConfigValues.newCheckoutFlow(): Flow = observe(GeneratedLocalFlagsSampleFeatureCheckout.newCheckout).map { it.value } + +public fun ConfigValues.checkoutVariantFlow(): Flow = + observe(GeneratedLocalFlagsSampleFeatureCheckout.checkoutVariant).map { + it.value + } + +public suspend fun ConfigValues.setNewCheckout(value: Boolean) { + override(GeneratedLocalFlagsSampleFeatureCheckout.newCheckout, value) +} diff --git a/sample/feature-checkout/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/checkout/CheckoutFlagsViewModel.kt b/sample/feature-checkout/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/checkout/CheckoutFlagsViewModel.kt new file mode 100644 index 0000000..73f827a --- /dev/null +++ b/sample/feature-checkout/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/checkout/CheckoutFlagsViewModel.kt @@ -0,0 +1,29 @@ +package dev.androidbroadcast.featured.sample.checkout + +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.stateIn +import kotlinx.coroutines.launch + +public class CheckoutFlagsViewModel( + private val configValues: ConfigValues, +) : ViewModel() { + public val newCheckout: StateFlow = + configValues + .newCheckoutFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), false) + // matches default declared in :sample:feature-checkout build.gradle.kts + + public val checkoutVariant: StateFlow = + configValues + .checkoutVariantFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), CheckoutVariant.LEGACY) + // matches default declared in :sample:feature-checkout build.gradle.kts + + public fun setNewCheckout(value: Boolean) { + viewModelScope.launch { configValues.setNewCheckout(value) } + } +} diff --git a/sample/feature-checkout/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/checkout/CheckoutVariant.kt b/sample/feature-checkout/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/checkout/CheckoutVariant.kt new file mode 100644 index 0000000..82fb01e --- /dev/null +++ b/sample/feature-checkout/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/checkout/CheckoutVariant.kt @@ -0,0 +1,7 @@ +package dev.androidbroadcast.featured.sample.checkout + +public enum class CheckoutVariant { + LEGACY, + NEW_SINGLE_PAGE, + NEW_MULTI_STEP, +} diff --git a/sample/feature-promotions/build.gradle.kts b/sample/feature-promotions/build.gradle.kts new file mode 100644 index 0000000..79824bd --- /dev/null +++ b/sample/feature-promotions/build.gradle.kts @@ -0,0 +1,54 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidKmpLibrary) + id("dev.androidbroadcast.featured") +} + +kotlin { + jvmToolchain(21) + + android { + namespace = "dev.androidbroadcast.featured.sample.promotions" + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() + minSdk = + libs.versions.android.minSdk + .get() + .toInt() + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + } + + iosX64() + iosArm64() + iosSimulatorArm64() + + jvm() + + sourceSets { + commonMain.dependencies { + api(project(":core")) + api(libs.kotlinx.coroutines.core) + // PromotionsFlagsViewModel is in this module's public API surface. + api(libs.androidx.lifecycle.viewmodel) + } + } + + sourceSets.commonMain.get().kotlin.srcDir( + tasks.named("generateConfigParam").map { it.outputs.files.singleFile }, + ) +} + +featured { + remoteFlags { + boolean("promo_banner_enabled", default = false) { + description = "Show the promotional banner on the main screen" + category = "promotions" + } + } +} diff --git a/sample/feature-promotions/src/androidMain/AndroidManifest.xml b/sample/feature-promotions/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/sample/feature-promotions/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/sample/feature-promotions/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/promotions/PromotionsFlagObservers.kt b/sample/feature-promotions/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/promotions/PromotionsFlagObservers.kt new file mode 100644 index 0000000..093c677 --- /dev/null +++ b/sample/feature-promotions/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/promotions/PromotionsFlagObservers.kt @@ -0,0 +1,15 @@ +package dev.androidbroadcast.featured.sample.promotions + +import dev.androidbroadcast.featured.ConfigValues +import dev.androidbroadcast.featured.generated.GeneratedRemoteFlagsSampleFeaturePromotions +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +public fun ConfigValues.promoBannerEnabledFlow(): Flow = + observe(GeneratedRemoteFlagsSampleFeaturePromotions.promoBannerEnabled).map { + it.value + } + +public suspend fun ConfigValues.setPromoBannerEnabled(value: Boolean) { + override(GeneratedRemoteFlagsSampleFeaturePromotions.promoBannerEnabled, value) +} diff --git a/sample/feature-promotions/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/promotions/PromotionsFlagsViewModel.kt b/sample/feature-promotions/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/promotions/PromotionsFlagsViewModel.kt new file mode 100644 index 0000000..659d12d --- /dev/null +++ b/sample/feature-promotions/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/promotions/PromotionsFlagsViewModel.kt @@ -0,0 +1,23 @@ +package dev.androidbroadcast.featured.sample.promotions + +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.stateIn +import kotlinx.coroutines.launch + +public class PromotionsFlagsViewModel( + private val configValues: ConfigValues, +) : ViewModel() { + public val promoBannerEnabled: StateFlow = + configValues + .promoBannerEnabledFlow() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), false) + // matches default declared in :sample:feature-promotions build.gradle.kts + + public fun setPromoBannerEnabled(value: Boolean) { + viewModelScope.launch { configValues.setPromoBannerEnabled(value) } + } +} diff --git a/sample/feature-ui/build.gradle.kts b/sample/feature-ui/build.gradle.kts new file mode 100644 index 0000000..5cb0be5 --- /dev/null +++ b/sample/feature-ui/build.gradle.kts @@ -0,0 +1,58 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidKmpLibrary) + id("dev.androidbroadcast.featured") +} + +kotlin { + jvmToolchain(21) + + android { + namespace = "dev.androidbroadcast.featured.sample.ui" + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() + minSdk = + libs.versions.android.minSdk + .get() + .toInt() + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + } + + iosX64() + iosArm64() + iosSimulatorArm64() + + jvm() + + sourceSets { + commonMain.dependencies { + api(project(":core")) + api(libs.kotlinx.coroutines.core) + // UiFlagsViewModel and MainButtonColor are in this module's public API surface. + api(libs.androidx.lifecycle.viewmodel) + } + } + + sourceSets.commonMain.get().kotlin.srcDir( + tasks.named("generateConfigParam").map { it.outputs.files.singleFile }, + ) +} + +featured { + localFlags { + boolean("main_button_red", default = true) { + description = "Tint the main button red (otherwise blue)" + category = "ui" + } + boolean("new_feature_section_enabled", default = true) { + description = "Show the new-feature section on the main screen" + category = "ui" + } + } +} diff --git a/sample/feature-ui/src/androidMain/AndroidManifest.xml b/sample/feature-ui/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/sample/feature-ui/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/sample/feature-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/ui/MainButtonColor.kt b/sample/feature-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/ui/MainButtonColor.kt new file mode 100644 index 0000000..83be512 --- /dev/null +++ b/sample/feature-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/ui/MainButtonColor.kt @@ -0,0 +1,7 @@ +package dev.androidbroadcast.featured.sample.ui + +public sealed interface MainButtonColor { + public data object Red : MainButtonColor + + public data object Blue : MainButtonColor +} diff --git a/sample/feature-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/ui/UiFlagObservers.kt b/sample/feature-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/ui/UiFlagObservers.kt new file mode 100644 index 0000000..e2a389d --- /dev/null +++ b/sample/feature-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/ui/UiFlagObservers.kt @@ -0,0 +1,19 @@ +package dev.androidbroadcast.featured.sample.ui + +import dev.androidbroadcast.featured.ConfigValues +import dev.androidbroadcast.featured.generated.GeneratedLocalFlagsSampleFeatureUi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +public fun ConfigValues.mainButtonRedFlow(): Flow = observe(GeneratedLocalFlagsSampleFeatureUi.mainButtonRed).map { it.value } + +public suspend fun ConfigValues.setMainButtonRed(value: Boolean) { + override(GeneratedLocalFlagsSampleFeatureUi.mainButtonRed, value) +} + +public fun ConfigValues.newFeatureSectionEnabledFlow(): Flow = + observe(GeneratedLocalFlagsSampleFeatureUi.newFeatureSectionEnabled).map { it.value } + +public suspend fun ConfigValues.setNewFeatureSectionEnabled(value: Boolean) { + override(GeneratedLocalFlagsSampleFeatureUi.newFeatureSectionEnabled, value) +} diff --git a/sample/feature-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/ui/UiFlagsViewModel.kt b/sample/feature-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/ui/UiFlagsViewModel.kt new file mode 100644 index 0000000..ba0ea7a --- /dev/null +++ b/sample/feature-ui/src/commonMain/kotlin/dev/androidbroadcast/featured/sample/ui/UiFlagsViewModel.kt @@ -0,0 +1,40 @@ +package dev.androidbroadcast.featured.sample.ui + +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 +import kotlinx.coroutines.launch + +public class UiFlagsViewModel( + private val configValues: ConfigValues, +) : ViewModel() { + public val mainButtonColor: StateFlow = + configValues + .mainButtonRedFlow() + .map { isRed -> if (isRed) MainButtonColor.Red else MainButtonColor.Blue } + .stateIn( + // matches the default declared in :sample:feature-ui build.gradle.kts + // (main_button_red = true → Red) + initialValue = MainButtonColor.Red, + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L), + ) + + public val newFeatureSectionEnabled: StateFlow = + configValues + .newFeatureSectionEnabledFlow() + // matches default declared in :sample:feature-ui build.gradle.kts + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000L), true) + + public fun setMainButtonColor(color: MainButtonColor) { + viewModelScope.launch { configValues.setMainButtonRed(color == MainButtonColor.Red) } + } + + public fun setNewFeatureSectionEnabled(value: Boolean) { + viewModelScope.launch { configValues.setNewFeatureSectionEnabled(value) } + } +} diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index 9274e79..a6f4dcd 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) alias(libs.plugins.skie) + id("dev.androidbroadcast.featured.application") } kotlin { @@ -34,6 +35,9 @@ kotlin { iosTarget.binaries.framework { baseName = "FeaturedSampleApp" isStatic = true + export(project(":sample:feature-checkout")) + export(project(":sample:feature-promotions")) + export(project(":sample:feature-ui")) } } @@ -50,11 +54,28 @@ kotlin { implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) - // :core types (ConfigValues, ConfigParam, InMemoryConfigValueProvider) appear in - // the public signatures of SampleApp / SampleViewModel — must be api to compile - // downstream consumers like :sample:desktop. Pre-existing leak from #182. + // :core is used directly in :sample:shared's iosMain (MainViewController.kt) + // for ConfigValues + InMemoryConfigValueProvider construction, and per-feature + // VM constructors take ConfigValues. Kept as api so platform shells reuse the + // transitive chain without re-declaring :core themselves. api(project(":core")) - implementation(project(":featured-registry")) + + // Per-feature ViewModel types (CheckoutFlagsViewModel, PromotionsFlagsViewModel, + // UiFlagsViewModel) appear in SampleApp's public signature — api so that platform + // shells (:sample:android-app, :sample:desktop, iosMain) can reference them. + api(project(":sample:feature-checkout")) + api(project(":sample:feature-promotions")) + api(project(":sample:feature-ui")) } } + + sourceSets.commonMain.get().kotlin.srcDir( + tasks.named("generateFeaturedRegistry").map { it.outputs.files.singleFile.parentFile }, + ) +} + +dependencies { + featuredAggregation(project(":sample:feature-checkout")) + featuredAggregation(project(":sample:feature-promotions")) + featuredAggregation(project(":sample:feature-ui")) } diff --git a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedSample.kt b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedSample.kt index 18399bb..0b7d54c 100644 --- a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedSample.kt +++ b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/FeaturedSample.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -25,32 +26,44 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel +import dev.androidbroadcast.featured.sample.checkout.CheckoutFlagsViewModel +import dev.androidbroadcast.featured.sample.checkout.CheckoutVariant +import dev.androidbroadcast.featured.sample.promotions.PromotionsFlagsViewModel +import dev.androidbroadcast.featured.sample.ui.MainButtonColor +import dev.androidbroadcast.featured.sample.ui.UiFlagsViewModel /** * Main sample screen demonstrating `@LocalFlag` and `@RemoteFlag` usage end-to-end. * - * @param configValues The shared [ConfigValues] instance. + * Each per-feature ViewModel is constructed by the platform shell (Activity, desktop main, + * iOS UIViewController) and passed in explicitly — demonstrating the per-module ConfigValues + * pattern where each module owns its own ConfigValues instance. + * + * @param uiViewModel ViewModel for UI-related flags from :sample:feature-ui. + * @param promotionsViewModel ViewModel for promotions flags from :sample:feature-promotions. + * @param checkoutViewModel ViewModel for checkout flags from :sample:feature-checkout. * @param onOpenDebugUi Callback to navigate to [FeatureFlagsDebugScreen]. * Non-null in debug builds only — the button is absent in release. * @param modifier Optional [Modifier]. */ @Composable -public fun FeaturedSample( - configValues: ConfigValues, +internal fun FeaturedSample( + uiViewModel: UiFlagsViewModel, + promotionsViewModel: PromotionsFlagsViewModel, + checkoutViewModel: CheckoutFlagsViewModel, onOpenDebugUi: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { - val viewModel: SampleViewModel = viewModel { SampleViewModel(configValues) } - val activate by viewModel.flagActive.collectAsStateWithLifecycle() - val buttonColor by viewModel.mainButtonColor.collectAsStateWithLifecycle() - val newFeatureSectionEnabled by viewModel.newFeatureSectionEnabled.collectAsStateWithLifecycle() - val promoBannerEnabled by viewModel.promoBannerEnabled.collectAsStateWithLifecycle() - val checkoutVariant by viewModel.checkoutVariant.collectAsStateWithLifecycle() + val buttonColor by uiViewModel.mainButtonColor.collectAsStateWithLifecycle() + val activate = buttonColor == MainButtonColor.Red + val newFeatureSectionEnabled by uiViewModel.newFeatureSectionEnabled.collectAsStateWithLifecycle() + val promoBannerEnabled by promotionsViewModel.promoBannerEnabled.collectAsStateWithLifecycle() + val checkoutVariant by checkoutViewModel.checkoutVariant.collectAsStateWithLifecycle() Column( modifier = modifier + .statusBarsPadding() .padding(16.dp) .fillMaxSize(), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -80,12 +93,16 @@ public fun FeaturedSample( ) { Checkbox( checked = activate, - onCheckedChange = viewModel::setMainButtonColorFlag, + onCheckedChange = { isChecked -> + uiViewModel.setMainButtonColor(if (isChecked) MainButtonColor.Red else MainButtonColor.Blue) + }, ) Text("Enable red button") } MainButton( - onClick = { viewModel.setMainButtonColorFlag(!activate) }, + onClick = { + uiViewModel.setMainButtonColor(if (activate) MainButtonColor.Blue else MainButtonColor.Red) + }, buttonColor = buttonColor, ) @@ -129,9 +146,9 @@ public fun FeaturedSample( } @Composable -public fun MainButton( +private fun MainButton( onClick: () -> Unit, - buttonColor: SampleViewModel.MainButtonColor, + buttonColor: MainButtonColor, modifier: Modifier = Modifier, ) { Button( @@ -140,8 +157,8 @@ public fun MainButton( ButtonDefaults.buttonColors( containerColor = when (buttonColor) { - SampleViewModel.MainButtonColor.Red -> Color.Red - SampleViewModel.MainButtonColor.Blue -> Color.Blue + MainButtonColor.Red -> Color.Red + MainButtonColor.Blue -> Color.Blue }, ), modifier = modifier.fillMaxWidth(), diff --git a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleApp.kt b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleApp.kt index 56a9cc0..1137218 100644 --- a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleApp.kt +++ b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleApp.kt @@ -4,25 +4,37 @@ package dev.androidbroadcast.featured import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import dev.androidbroadcast.featured.sample.checkout.CheckoutFlagsViewModel +import dev.androidbroadcast.featured.sample.promotions.PromotionsFlagsViewModel +import dev.androidbroadcast.featured.sample.ui.UiFlagsViewModel /** * Root composable for the sample application. * - * [onOpenDebugUi] is non-null in debug builds (wired by the debug source set) + * Each ViewModel corresponds to one feature module's [ConfigValues] instance — + * demonstrating the per-module ConfigValues pattern. + * + * [onOpenDebugUi] is non-null in debug builds (wired by the platform shell) * and null in release builds, so no debug UI entry point is compiled into release. * - * @param configValues The shared [ConfigValues] instance. + * @param uiViewModel ViewModel for UI-related flags from :sample:feature-ui. + * @param promotionsViewModel ViewModel for promotions flags from :sample:feature-promotions. + * @param checkoutViewModel ViewModel for checkout flags from :sample:feature-checkout. * @param onOpenDebugUi Callback to navigate to the debug UI screen. Null in release builds. * @param modifier Optional [Modifier] for the root composable. */ @Composable public fun SampleApp( - configValues: ConfigValues, + uiViewModel: UiFlagsViewModel, + promotionsViewModel: PromotionsFlagsViewModel, + checkoutViewModel: CheckoutFlagsViewModel, onOpenDebugUi: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { FeaturedSample( - configValues = configValues, + uiViewModel = uiViewModel, + promotionsViewModel = promotionsViewModel, + checkoutViewModel = checkoutViewModel, onOpenDebugUi = onOpenDebugUi, modifier = modifier, ) diff --git a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleFeatureFlags.kt b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleFeatureFlags.kt deleted file mode 100644 index b75c4f5..0000000 --- a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleFeatureFlags.kt +++ /dev/null @@ -1,84 +0,0 @@ -package dev.androidbroadcast.featured - -/** - * Checkout flow variant used to demonstrate multivariate (enum) feature flags. - */ -public enum class CheckoutVariant { - /** The original multi-screen checkout flow. */ - LEGACY, - - /** New single-page checkout (A/B experiment arm A). */ - NEW_SINGLE_PAGE, - - /** New multi-step checkout with progress indicator (A/B experiment arm B). */ - NEW_MULTI_STEP, -} - -/** - * Feature flags for the sample app. - * - * In a real consumer project, Boolean/Int/String flags would be declared in - * `build.gradle.kts` using the Featured Gradle DSL, and the plugin would generate - * typed `ConfigParam` objects and `ConfigValues` extension functions automatically: - * - * ```kotlin - * // build.gradle.kts - * featured { - * localFlags { - * boolean("main_button_red", default = true) { category = "ui" } - * boolean("new_feature_section_enabled", default = true) { category = "ui" } - * } - * remoteFlags { - * boolean("promo_banner_enabled", default = false) { - * description = "Show promotional banner" - * } - * } - * } - * ``` - * - * Enum-typed flags (like [checkoutVariant]) are declared manually as `ConfigParam` - * until enum support is added to the DSL. - * - * The sample module is part of the library's own build and cannot apply the plugin - * to itself, so all flags are declared manually here for demonstration purposes. - */ -public object SampleFeatureFlags { - public val mainButtonRed: ConfigParam = - ConfigParam( - key = "main_button_red", - defaultValue = true, - description = "Enable red color for the main button", - category = "ui", - ) - - public val newFeatureSectionEnabled: ConfigParam = - ConfigParam( - key = "new_feature_section_enabled", - defaultValue = true, - description = "Show the new feature section in the main screen", - category = "ui", - ) - - public val newCheckout: ConfigParam = - ConfigParam( - key = "new_checkout", - defaultValue = false, - description = "Enable the redesigned checkout flow", - ) - - public val promoBannerEnabled: ConfigParam = - ConfigParam( - key = "promo_banner_enabled", - defaultValue = false, - description = "Show a promotional banner on the main screen (remote-controlled)", - category = "promotions", - ) - - public val checkoutVariant: ConfigParam = - ConfigParam( - key = "checkout_variant", - defaultValue = CheckoutVariant.LEGACY, - description = "Controls which checkout flow variant is shown to the user", - category = "checkout", - ) -} diff --git a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt b/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt deleted file mode 100644 index 420da49..0000000 --- a/sample/shared/src/commonMain/kotlin/dev/androidbroadcast/featured/SampleViewModel.kt +++ /dev/null @@ -1,63 +0,0 @@ -package dev.androidbroadcast.featured - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch - -public class SampleViewModel( - private val configValues: ConfigValues, -) : ViewModel() { - public val flagActive: StateFlow = - configValues.asStateFlow(SampleFeatureFlags.mainButtonRed, viewModelScope) - - public val mainButtonColor: StateFlow = - flagActive - .map { isRed -> - if (isRed) MainButtonColor.Red else MainButtonColor.Blue - }.stateIn( - initialValue = MainButtonColor.Default, - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5_000L), - ) - - public fun setMainButtonColorFlag(value: Boolean) { - viewModelScope.launch { - configValues.override(SampleFeatureFlags.mainButtonRed, value) - } - } - - /** - * Controls visibility of the "New Feature" section. - * Demonstrates the [ConfigParam.isEnabled] guard pattern for entry-point gating. - */ - public val newFeatureSectionEnabled: StateFlow = - configValues.asStateFlow(SampleFeatureFlags.newFeatureSectionEnabled, viewModelScope) - - /** - * Whether the promotional banner should be shown. - * In production this would be driven by Firebase Remote Config. - */ - public val promoBannerEnabled: StateFlow = - configValues.asStateFlow(SampleFeatureFlags.promoBannerEnabled, viewModelScope) - - /** - * The active checkout variant, driven remotely. - * Demonstrates multivariate enum flags resolved from a remote provider. - */ - public val checkoutVariant: StateFlow = - configValues.asStateFlow(SampleFeatureFlags.checkoutVariant, viewModelScope) - - public sealed interface MainButtonColor { - public data object Red : MainButtonColor - - public data object Blue : MainButtonColor - - public companion object Companion { - public val Default: MainButtonColor = Blue - } - } -} diff --git a/sample/shared/src/iosMain/kotlin/dev/androidbroadcast/featured/MainViewController.kt b/sample/shared/src/iosMain/kotlin/dev/androidbroadcast/featured/MainViewController.kt index 254a6b0..64a0715 100644 --- a/sample/shared/src/iosMain/kotlin/dev/androidbroadcast/featured/MainViewController.kt +++ b/sample/shared/src/iosMain/kotlin/dev/androidbroadcast/featured/MainViewController.kt @@ -1,15 +1,37 @@ -@file:Suppress("RedundantVisibilityModifier", "ktlint:standard:function-naming") +@file:Suppress("ktlint:standard:function-naming") package dev.androidbroadcast.featured import androidx.compose.ui.window.ComposeUIViewController +import dev.androidbroadcast.featured.sample.checkout.CheckoutFlagsViewModel +import dev.androidbroadcast.featured.sample.promotions.PromotionsFlagsViewModel +import dev.androidbroadcast.featured.sample.ui.UiFlagsViewModel import platform.UIKit.UIViewController -// ConfigValues is constructed once per UIViewController and passed explicitly. -// In a real app this instance would come from a shared DI container. +// Each feature module gets its own ConfigValues backed by the same in-memory provider. +// Per-module ConfigValues is the pattern Featured is designed around: flags are scoped +// to the module that declared them. +// In a real app these instances would come from a shared DI container. public fun MainViewController(): UIViewController { - val configValues = ConfigValues(localProvider = InMemoryConfigValueProvider()) + val sharedLocalProvider = InMemoryConfigValueProvider() + + val checkoutConfigValues = ConfigValues(localProvider = sharedLocalProvider) + val promotionsConfigValues = ConfigValues(localProvider = sharedLocalProvider) + val uiConfigValues = ConfigValues(localProvider = sharedLocalProvider) + // No debug aggregator on iOS — the iOS shell does not wire a debug-UI entry. + // The Android shell builds a fourth `ConfigValues` for the debug screen. + + // VMs are constructed once per UIViewController — ConfigValues lifetimes are tied + // to this root view controller's lifetime. + val checkoutViewModel = CheckoutFlagsViewModel(checkoutConfigValues) + val promotionsViewModel = PromotionsFlagsViewModel(promotionsConfigValues) + val uiViewModel = UiFlagsViewModel(uiConfigValues) + return ComposeUIViewController { - SampleApp(configValues = configValues) + SampleApp( + uiViewModel = uiViewModel, + promotionsViewModel = promotionsViewModel, + checkoutViewModel = checkoutViewModel, + ) } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 201871b..4c5cd99 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,6 +2,7 @@ rootProject.name = "Featured" enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") pluginManagement { + includeBuild("featured-gradle-plugin") @Suppress("UnstableApiUsage") repositories { @@ -36,13 +37,14 @@ plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } -include(":featured-gradle-plugin") include(":sample:shared") +include(":sample:feature-checkout") +include(":sample:feature-promotions") +include(":sample:feature-ui") include(":sample:android-app") include(":sample:desktop") include(":core") include(":featured-compose") -include(":featured-registry") include(":featured-debug-ui") include(":featured-testing")