diff --git a/TICKETS.md b/TICKETS.md index 4726b064..2444a7de 100644 --- a/TICKETS.md +++ b/TICKETS.md @@ -172,11 +172,11 @@ External multimodule OSS validation of meta-build axioms (structure over configu type-owned plugins, attrs-only call sites, closed matrix). **Not** another in-repo sample. Open coding: **F-115** (`in_progress`, **`priority: now`**, **cron may continue**). **F-094** Portal remains `blocked` (human). -4h workers: pick F-115 phase C remainder (**F27 library flavors** / Phase D) while gated; if no ticket has `priority: now` / `cron may continue`, respond `[SILENT]` (audit 2026-07-29 gate — interactive “Go ahead” alone is not enough). +4h workers: pick F-115 **Phase D** case study (or residual dogfood polish) while gated; if no ticket has `priority: now` / `cron may continue`, respond `[SILENT]` (audit 2026-07-29 gate — interactive “Go ahead” alone is not enough). | ID | Status | Title | Notes | |----|--------|-------|-------| -| F-115 | in_progress · **priority: now** · **cron may continue** | Dogfood Now in Android under Forma | Primary OSS target [`android/nowinandroid`](https://github.com/android/nowinandroid). Design: [`docs/DOGFOOD-NIA.md`](docs/DOGFOOD-NIA.md). **Phase A+B+C through analytics+notifications done:** Hilt/Room/Firebase/Proto DS + features + WM + flavors + designsystem + core:ui + network + **`core:analytics`** + **`core:notifications`{res,util}** (F31). Findings F16–F31. **Next phase C:** F27 library flavors / Phase D case study. No `androidLibrary`. | +| F-115 | in_progress · **priority: now** · **cron may continue** | Dogfood Now in Android under Forma | Primary OSS target [`android/nowinandroid`](https://github.com/android/nowinandroid). Design: [`docs/DOGFOOD-NIA.md`](docs/DOGFOOD-NIA.md). **Phase A+B+C done through F27 library flavors:** Hilt/Room/Firebase/Proto DS + features + WM + binary flavors + designsystem + core:ui + network + analytics/notifications + **library `productFlavors` + `.forProductFlavor` deps** (F27). Findings F16–F31 (F27 closed). **Next:** Phase D case study. No `androidLibrary`. | ## Backlog (lower priority / historical GitHub) diff --git a/docs/CALL-SITE-SURFACE.md b/docs/CALL-SITE-SURFACE.md index db27e96b..9c010985 100644 --- a/docs/CALL-SITE-SURFACE.md +++ b/docs/CALL-SITE-SURFACE.md @@ -149,16 +149,19 @@ default). **Not in scope:** Play App Signing backend integration, library AAR signing APIs. -## Product flavors (F-115 / NiA F7) +## Product flavors (F-115 / NiA F7 + F27) | Concern | Owner | Notes | |---------|--------|--------| -| Flavor dimensions + product flavors | **`androidBinary` call site only** (`productFlavors: List`) | Wired to AGP `ApplicationExtension.flavorDimensions` + `productFlavors` | +| Flavor model | **`FormaProductFlavor`** (one global way) | Same data class for binary + library; libraries ignore APK-only fields (`applicationIdSuffix` / `versionNameSuffix`) | +| Binary flavor dimensions | **`androidBinary(productFlavors=…)`** | Wired to AGP `ApplicationExtension.flavorDimensions` + `productFlavors` | +| Library flavor dimensions | **`androidUtil` / `impl` / `androidApp` / `composeWidget` (+ thin Hilt wrappers)** (F27) | Wired via `AndroidLibraryFeatureConfiguration` → `LibraryExtension.applyProductFlavors`; registers `src//kotlin`. Intermediate consumers of flavored libraries must declare the **same** dimension list (AGP variant matching). | | Build types | **`BuildConfiguration`** (unchanged) | Flavors are **not** stuffed into build-type map | -| Library / `impl` / `androidApp` flavor API | **None (v1)** | Unflavored libraries resolve against flavored APKs; multi-module `demoImplementation` is a documented gap | +| Flavor-scoped external deps | **`NamedDependency.forProductFlavor` / `PlatformDependency.forProductFlavor`** | Maps onto AGP `{flavor}Implementation` via `ConfigurationType.CustomConfiguration`; preserves transitive + feature-flag gates | | Raw `android { productFlavors { … } }` | **Rejected as happy path** | Escape hatch only | +| Free-form config name strings at call sites | **Rejected** | Prefer `.forProductFlavor("prod")` over hand-written `"prodImplementation"` | -**Call-site shape (NiA `demo` / `prod`):** +**Binary call-site shape (NiA `demo` / `prod`):** ```kotlin import tools.forma.android.utils.FormaProductFlavor @@ -183,11 +186,40 @@ androidBinary( // Tasks: assembleDemoDebug / assembleProdDebug (not bare assembleDebug) ``` -**Empty list (default)** keeps a single unflavored APK so existing samples stay +**Library + flavor-scoped deps (NiA analytics / notifications):** + +```kotlin +import tools.forma.android.utils.FormaProductFlavor + +val contentTypeFlavors = listOf( + FormaProductFlavor(name = "demo", dimension = "contentType"), + FormaProductFlavor(name = "prod", dimension = "contentType"), +) + +androidUtil( + packageName = "com.example.analytics", + productFlavors = contentTypeFlavors, + dependencies = transitiveDeps( + "androidx.compose.runtime:runtime:…", + ) + + transitivePlatform("com.google.firebase:firebase-bom:33.16.0") + .forProductFlavor("prod") + + transitiveDeps("com.google.firebase:firebase-analytics") + .forProductFlavor("prod"), +) +// Sources: src/main/kotlin + src/demo/kotlin + src/prod/kotlin +// prodImplementation(firebase-bom platform) + prodImplementation(firebase-analytics) +``` + +**Empty list (default)** keeps unflavored targets so existing samples stay `assembleDebug`. Optional attrs on [FormaProductFlavor]: `versionNameSuffix`, `matchingFallbacks`, `manifestPlaceholders`, `buildConfigFields` (BuildConfig fields apply only when project-global `buildFeatures.buildConfig` is already on). +Other library DSLs (`impl`, `uiLibrary`, …) can gain `productFlavors` later by +forwarding into `AndroidLibraryFeatureConfiguration` — **androidUtil** is the +dogfood path for F27; do not invent per-module raw AGP blocks. + ## AGP BuildFeatures (F-091 / GH #88) AGP `BuildFeatures` flags are **project-global by default** (all **off**), with diff --git a/docs/DOGFOOD-NIA.md b/docs/DOGFOOD-NIA.md index 1d28f544..4fac2641 100644 --- a/docs/DOGFOOD-NIA.md +++ b/docs/DOGFOOD-NIA.md @@ -1,6 +1,6 @@ # Dogfood: Now in Android → Forma (F-115) -**Status:** `in_progress` — phase C through **analytics + notifications** green (F27 library flavors next) +**Status:** `in_progress` — phase C through **library product flavors (F27)** green; **Phase D** case study next **Upstream:** [android/nowinandroid](https://github.com/android/nowinandroid) (Apache-2.0) **Pinned checkout (local):** `/Users/claw/work/nowinandroid` @ `7d45eae` (main tip when cloned 2026-07-29) **Dogfood fork:** `/Users/claw/work/nowinandroid-forma` @@ -157,8 +157,8 @@ These are the **value** of dogfooding — expected friction, not blockers to ign ### F7 — Product flavors `demo` / `prod` - Affects source sets and dependency variants (`prodImplementation` FCM on sync). -- **Resolved (binary v1, 2026-07-31):** `FormaProductFlavor` + `androidBinary(productFlavors=…)` — binary-only; `BuildConfiguration` stays build-types. NiA spike: `demo`/`prod` on `contentType`; `assembleDemoDebug` / `assembleProdDebug`. -- **Still gap (F27):** library-level productFlavors + `demoImplementation`/`prodImplementation` edges (e.g. sync FCM) — not in v1; unflavored libraries resolve against flavored APK. +- **Resolved (binary v1, 2026-07-31):** `FormaProductFlavor` + `androidBinary(productFlavors=…)` — binary; `BuildConfiguration` stays build-types. NiA spike: `demo`/`prod` on `contentType`; `assembleDemoDebug` / `assembleProdDebug`. +- **Resolved (library F27, 2026-08-01):** same `FormaProductFlavor` on `androidUtil` / library feature config + `LibraryExtension.applyProductFlavors` + `src//kotlin`; flavor-scoped deps via `.forProductFlavor("prod")` → `prodImplementation`. ## Phased execution plan @@ -352,7 +352,7 @@ Workspace: same **`forma-spike`** external tree. | Spike binary | `hiltFirebaseBinary(productFlavors = demo+prod contentType)`; demo `applicationIdSuffix = ".demo"` | | Tasks | `:binary:assembleDemoDebug` + `:binary:assembleProdDebug` (bare `assembleDebug` gone once flavored) | | **F7** | closed for APK root | -| **F27** | multi-module library flavors / variant deps (e.g. sync FCM) — not in v1; unflavored libraries resolve against flavored APK. | +| **F27** | closed in library flavors section below | | Verify | `forma-spike` assembleDemoDebug + assembleProdDebug green after `publish-local 0.1.3-NIA` | **Still open in phase C (pre-designsystem):** full designsystem / core.ui port. @@ -405,16 +405,33 @@ Workspace: same **`forma-spike`** external tree. |------|--------| | Module | `core-analytics-android-util` — `AnalyticsHelper` / `AnalyticsEvent` / Stub + NoOp + `LocalAnalyticsHelper` | | Type | plain **`hiltAndroidUtil`** + Compose **runtime** library stack for CompositionLocal (**F18** sibling; `compose=false` target — F11) | -| Bind | **StubAnalyticsHelper** for **all** product flavors (F27 prod `FirebaseAnalyticsHelper` + flavor source sets deferred) | +| Bind (pre-F27) | **StubAnalyticsHelper** for all flavors until library flavors section | | Module | `core-notifications-res` **`androidRes`** + `core-notifications-android-util` **`hiltAndroidUtil`** | | **F31 / F23** | Tray strings + vector icon on **res** module (unique namespace `…notifications.res`); util imports `R as NotificationsR` — androidUtil still no `res/` | -| Bind | **SystemTrayNotifier** for all flavors (validates res split + data edge; demo NoOp deferred with F27) | +| Bind (pre-F27) | **SystemTrayNotifier** for all flavors until library flavors section | | Data | UserData toggles → analytics events; News `sync()` → notifier for new followed-topic items after onboard | | UI | `core-ui` → analytics; NewsFeed logs `news_resource_opened`; root `CompositionLocalProvider` + `TrackScreenViewEvent` | | Verify | `forma-spike` `:binary:assembleDemoDebug` + `:binary:assembleProdDebug` → **BUILD SUCCESSFUL** (645 tasks); APKs ~22 MB | | Version | `0.16.0-nia-forma-analytics-notifications` | -**Still open in phase C:** F27 library flavors / prod Firebase analytics + demo NoOp notifier source sets; optional case-study Phase D. +### Phase C — Library product flavors (F27) ✅ (2026-08-01) + +| Step | Result | +|------|--------| +| Engine | `AndroidLibraryFeatureConfiguration.productFlavors` + `LibraryExtension.applyProductFlavors(plan)` (no-op empty; `src//kotlin`; APK-only fields ignored) | +| Model | Same **`FormaProductFlavor`** as binary — one global way; KDoc no longer binary-only | +| DSL | `androidUtil` / `androidUtilTarget(productFlavors=…)` + thin `hiltAndroidUtil` forward | +| Flavor-scoped deps | `NamedDependency.forProductFlavor` / `PlatformDependency.forProductFlavor` → `CustomConfiguration("${flavor}Implementation")`; preserves transitive + feature flags | +| Unit tests | `FormaProductFlavorTest` library plan reuse; `DepsModelAndPluginHappyPathTest` flavor config mapping | +| Spike analytics | `hiltAndroidUtil(productFlavors=demo+prod)`; Hilt modules in `src/demo` (Stub) / `src/prod` (Firebase + `FirebaseAnalyticsHelper`); `prod` BOM + analytics via `.forProductFlavor("prod")` | +| Spike notifications | same flavors; `src/demo` NoOpNotifier bind / `src/prod` SystemTrayNotifier bind; main keeps implementations | +| Spike sync | optional `prodImplementation` FCM via `.forProductFlavor("prod")` (deps-only; no new FCM source) | +| Docs | `CALL-SITE-SURFACE.md` library flavors + flavor-scoped deps | +| Verify | `plugins` `:android:test` + `:deps:test`; `publish-local 0.1.3-NIA`; spike `:binary:assembleDemoDebug` + `:binary:assembleProdDebug` | +| Version | `0.17.0-nia-forma-library-flavors` | +| **F27** | **closed** | + +**Still open:** Phase D case-study write-up (optional remaining Retrofit/prod network out of scope). ### Phase D — Case study write-up @@ -453,7 +470,7 @@ Workspace: same **`forma-spike`** external tree. | F24 | 2026-07-31 | `deps()` overload mix | Cannot pass `.ksp` NamedDependency and `target()` FormaTarget in one `deps(...)` call (distinct overloads). Compose `transitiveDeps + deps(ksp) + deps(target)`. | | F25 | 2026-07-31 | Protobuf needs type-owned apply on JVM library | NiA `datastore-proto` is pure JVM + `com.google.protobuf`. Engine needed `libraryTarget` (like `androidUtilTarget`) so Path B `protobufLibrary` can pass derived type. Call sites stay attrs-only. | | F26 | 2026-07-31 | Proto lite supers on consumers | `UserPreferences` / enums extend protobuf lite; androidUtil consumer must `transitiveDeps(protobuf-kotlin-lite)` even when proto module already depends on it (project dep does not re-export non-api runtime the same way as NiA `api(libs.protobuf…)`). | -| F27 | 2026-07-31 | Library product flavors / variant deps | Binary-only `FormaProductFlavor` closes F7 for APK. NiA also flavors libraries + `prodImplementation(FCM)` on sync + **analytics demo/prod binds** + **notifications demo NoOp / prod tray**. Spike binds Stub analytics + SystemTray notifier for **all** flavors until library flavor attrs exist. Future: optional library flavor attrs or type-owned missingDimensionStrategy — do **not** put free-form `android { productFlavors }` on every module. | +| F27 | 2026-07-31 | Library product flavors / variant deps | **Closed 2026-08-01:** same `FormaProductFlavor` on `androidUtil` + `LibraryExtension.applyProductFlavors` + `src//kotlin`; flavor-scoped deps via `.forProductFlavor` → `prodImplementation`. Spike analytics demo Stub / prod Firebase; notifications demo NoOp / prod SystemTray; optional sync FCM prod dep. No raw `android { productFlavors }` at call sites. | ## Local reference commands diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 9de4a758..c0f80e59 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -2,6 +2,30 @@ Newest entries first. +## 2026-08-01 — F-115: NiA dogfood library product flavors (F27) + +- **Ticket:** F-115 still `in_progress` · `priority: now` · `cron may continue` (Phase D case study next) +- **Skills/modes:** Grok Build CLI `--mode full` (design/plan + implement + check) model `grok-4.5`; Hermes finish path (publish-local + spike verify + PROGRESS/PR) +- **Engine (`plugins/`):** + - `LibraryExtension.applyProductFlavors` + `AndroidLibraryFeatureConfiguration.productFlavors` + flavor `src//kotlin` dirs + - DSL attr `productFlavors` on `androidUtil` / `androidUtilTarget` / `impl` / `androidApp` / `composeWidget` (empty default) + - `NamedDependency.forProductFlavor` / `PlatformDependency.forProductFlavor` → AGP `prodImplementation` via `CustomConfiguration` (no free-form config strings) + - Unit tests: `:android:test` + `:deps:test` **BUILD SUCCESSFUL** +- **Publish:** `bash scripts/publish-local.sh 0.1.3-NIA` → mavenLocal +- **External spike** `/Users/claw/work/nowinandroid-forma/forma-spike`: + - `niaContentTypeFlavors` shared list in forma-defs; forward on hilt wrappers + - analytics: demo Stub / prod FirebaseAnalyticsHelper + flavor-scoped BOM+analytics + - notifications: demo NoOp / prod SystemTray binds via flavor source sets + - intermediate consumers (data/domain/ui/features/root/sync) share library flavors for AGP variant match + - binary `0.17.0-nia-forma-library-flavors` +- **Verify (real host):** + - `forma-spike` `./gradlew :binary:assembleDemoDebug :binary:assembleProdDebug` → **BUILD SUCCESSFUL** (945 tasks) + - APKs: `binary-demo-debug.apk` ~22 MB, `binary-prod-debug.apk` ~22 MB +- **Docs:** CALL-SITE-SURFACE flavors F7+F27; DOGFOOD-NIA F27 closed; TICKETS F-115 Phase D next +- **Commits/PRs:** this branch → PR base `v2` +- **Blockers:** none product; F-094 Portal still human-blocked +- **Next:** Phase D case study write-up (before/after LOC, config notes) + ## 2026-08-01 — F-115: NiA dogfood core:analytics + core:notifications - **Ticket:** F-115 still `in_progress` · `priority: now` · `cron may continue` (F27 library flavors / Phase D next) diff --git a/plugins/android/src/main/java/androidApp.kt b/plugins/android/src/main/java/androidApp.kt index 30225a3a..5bce727c 100644 --- a/plugins/android/src/main/java/androidApp.kt +++ b/plugins/android/src/main/java/androidApp.kt @@ -7,6 +7,7 @@ import tools.forma.android.feature.kotlinAndroidFeatureDefinition import tools.forma.android.target.AndroidTargetRegistry import tools.forma.android.target.AndroidTargetTypes import tools.forma.android.utils.BuildConfiguration +import tools.forma.android.utils.FormaProductFlavor import tools.forma.android.validation.disallowResources import tools.forma.android.visibility.Public import tools.forma.android.visibility.Visibility @@ -43,6 +44,8 @@ fun Project.androidApp( manifestPlaceholders: Map = emptyMap(), /** Enable Jetpack Compose; defaults to project-wide `compose` setting. */ compose: Boolean = Forma.settings.compose, + /** Library product flavors (F-115 / NiA F27); empty = unflavored. */ + productFlavors: List = emptyList(), ) { disallowResources() @@ -58,6 +61,7 @@ fun Project.androidApp( manifestPlaceholders, selfValidator = selfV, compose = compose, + productFlavors = productFlavors, ) applyFeatures( androidLibraryFeatureDefinition(libraryFeatureConfiguration), diff --git a/plugins/android/src/main/java/androidUtil.kt b/plugins/android/src/main/java/androidUtil.kt index 2b414be1..e9d6be76 100644 --- a/plugins/android/src/main/java/androidUtil.kt +++ b/plugins/android/src/main/java/androidUtil.kt @@ -1,4 +1,5 @@ import tools.forma.android.target.AndroidTargetTypes +import tools.forma.android.utils.FormaProductFlavor import tools.forma.owners.NoOwner import tools.forma.owners.Owner import tools.forma.android.visibility.Public @@ -31,6 +32,12 @@ fun Project.androidUtil( testDependencies: FormaDependency = emptyDependency(), /** Enable Jetpack Compose; defaults to project-wide `compose` setting. */ compose: Boolean = Forma.settings.compose, + /** + * Library product flavors (F-115 / NiA F27). Same [FormaProductFlavor] model as + * `androidBinary`; empty = unflavored. Pair with `NamedDependency.forProductFlavor` / + * `PlatformDependency.forProductFlavor` for AGP `prodImplementation` edges. + */ + productFlavors: List = emptyList(), ) { androidUtilTarget( type = AndroidTargetTypes.androidUtil, @@ -40,5 +47,6 @@ fun Project.androidUtil( dependencies = dependencies, testDependencies = testDependencies, compose = compose, + productFlavors = productFlavors, ) } diff --git a/plugins/android/src/main/java/androidUtilTarget.kt b/plugins/android/src/main/java/androidUtilTarget.kt index 4d35c3b5..ec98cbf4 100644 --- a/plugins/android/src/main/java/androidUtilTarget.kt +++ b/plugins/android/src/main/java/androidUtilTarget.kt @@ -4,6 +4,7 @@ import tools.forma.android.feature.applyFeatures import tools.forma.android.feature.kotlinAndroidFeatureDefinition import tools.forma.android.feature.processorConfigurationFeatures import tools.forma.android.target.AndroidTargetRegistry +import tools.forma.android.utils.FormaProductFlavor import tools.forma.android.validation.disallowResources import tools.forma.android.visibility.Public import tools.forma.android.visibility.Visibility @@ -25,6 +26,9 @@ import tools.forma.validation.validate * (e.g. dogfood `roomAndroidUtil`) so plugin identity stays on the type while the * AGP/util wiring is shared. Call sites of derived DSLs remain attributes-only — * never pass plugin ids here. + * + * [productFlavors] (F-115 / NiA F27): optional library product flavors using the + * same [FormaProductFlavor] model as `androidBinary`. Empty = unflavored. */ fun Project.androidUtilTarget( type: TargetType, @@ -35,6 +39,7 @@ fun Project.androidUtilTarget( testDependencies: FormaDependency = emptyDependency(), /** Enable Jetpack Compose; defaults to project-wide `compose` setting. */ compose: Boolean = Forma.settings.compose, + productFlavors: List = emptyList(), ) { disallowResources() @@ -44,6 +49,7 @@ fun Project.androidUtilTarget( val androidFeatureConfig = AndroidLibraryFeatureConfiguration( packageName = packageName, compose = compose, + productFlavors = productFlavors, ) applyFeatures( diff --git a/plugins/android/src/main/java/composeWidget.kt b/plugins/android/src/main/java/composeWidget.kt index 1a279528..a10a68fe 100644 --- a/plugins/android/src/main/java/composeWidget.kt +++ b/plugins/android/src/main/java/composeWidget.kt @@ -5,6 +5,7 @@ import tools.forma.android.feature.applyFeatures import tools.forma.android.feature.kotlinAndroidFeatureDefinition import tools.forma.android.target.AndroidTargetRegistry import tools.forma.android.target.AndroidTargetTypes +import tools.forma.android.utils.FormaProductFlavor import tools.forma.android.visibility.Public import tools.forma.android.visibility.Visibility import tools.forma.deps.core.FormaDependency @@ -32,7 +33,9 @@ fun Project.composeWidget( androidTestDependencies: FormaDependency = emptyDependency(), testInstrumentationRunner: String = androidJunitRunner, consumerMinificationFiles: Set = emptySet(), - manifestPlaceholders: Map = emptyMap() + manifestPlaceholders: Map = emptyMap(), + /** Library product flavors (F-115 / NiA F27); empty = unflavored. */ + productFlavors: List = emptyList(), ) { val selfV = AndroidTargetRegistry.selfValidator(AndroidTargetTypes.composeWidget).asValidator() selfV.validate(target) @@ -44,6 +47,7 @@ fun Project.composeWidget( consumerMinificationFiles = consumerMinificationFiles, manifestPlaceholders = manifestPlaceholders, compose = true, + productFlavors = productFlavors, selfValidator = selfV ) diff --git a/plugins/android/src/main/java/impl.kt b/plugins/android/src/main/java/impl.kt index 922cc911..abe368dd 100644 --- a/plugins/android/src/main/java/impl.kt +++ b/plugins/android/src/main/java/impl.kt @@ -7,6 +7,7 @@ import tools.forma.android.feature.kotlinAndroidFeatureDefinition import tools.forma.android.target.AndroidTargetRegistry import tools.forma.android.target.AndroidTargetTypes import tools.forma.android.utils.BuildConfiguration +import tools.forma.android.utils.FormaProductFlavor import tools.forma.deps.core.FormaDependency import tools.forma.deps.core.applyDependencies import tools.forma.deps.core.applyTargetPlugins @@ -29,6 +30,8 @@ import tools.forma.validation.validate * use the dedicated `viewBinding` target type instead (always on). * @param compose enable Jetpack Compose for this target; defaults to project-wide * [androidProjectConfiguration] `compose` flag. + * @param productFlavors optional library product flavors (F-115 / NiA F27); empty = unflavored. + * Required when this impl depends on flavored libraries (matching AGP dimensions). */ fun Project.impl( packageName: String, @@ -45,7 +48,8 @@ fun Project.impl( testInstrumentationRunner: String = androidJunitRunner, buildConfiguration: BuildConfiguration = BuildConfiguration(), consumerMinificationFiles: Set = emptySet(), - manifestPlaceholders: Map = emptyMap() + manifestPlaceholders: Map = emptyMap(), + productFlavors: List = emptyList(), ) { val selfV = AndroidTargetRegistry.selfValidator(AndroidTargetTypes.impl).asValidator() @@ -59,7 +63,8 @@ fun Project.impl( manifestPlaceholders, selfValidator = selfV, viewBinding = viewBinding, - compose = compose + compose = compose, + productFlavors = productFlavors, ) applyFeatures( androidLibraryFeatureDefinition(libraryFeatureConfiguration), diff --git a/plugins/android/src/main/java/tools/forma/android/feature/AndroidBinary.kt b/plugins/android/src/main/java/tools/forma/android/feature/AndroidBinary.kt index af0073e4..ae9c9dda 100644 --- a/plugins/android/src/main/java/tools/forma/android/feature/AndroidBinary.kt +++ b/plugins/android/src/main/java/tools/forma/android/feature/AndroidBinary.kt @@ -24,8 +24,9 @@ import tools.forma.validation.validator * [signingConfigs] / [buildTypeSigning] are **per-binary** (F-097 / GH #51) — * APK signing identity lives only on the composition root, not on library shells. * - * [productFlavors] are **per-binary** (F-115 / NiA F7) — flavor dimensions live - * on the APK root only in v1; [BuildConfiguration] stays build-types only. + * [productFlavors] on the APK root (F-115 / NiA F7). Libraries that need matching + * dimensions use the same [FormaProductFlavor] list on `androidUtil` (F27); + * [BuildConfiguration] stays build-types only. */ data class AndroidBinaryFeatureConfiguration( val packageName: String, diff --git a/plugins/android/src/main/java/tools/forma/android/feature/AndroidLibrary.kt b/plugins/android/src/main/java/tools/forma/android/feature/AndroidLibrary.kt index 9f5e8ba8..b3105fde 100644 --- a/plugins/android/src/main/java/tools/forma/android/feature/AndroidLibrary.kt +++ b/plugins/android/src/main/java/tools/forma/android/feature/AndroidLibrary.kt @@ -4,8 +4,10 @@ import androidJunitRunner import com.android.build.api.dsl.LibraryExtension import tools.forma.android.target.LibraryTargetTemplate import tools.forma.android.utils.BuildConfiguration +import tools.forma.android.utils.FormaProductFlavor import tools.forma.android.utils.applyCoreLibraryDesugaring import tools.forma.android.utils.applyFrom +import tools.forma.android.utils.applyProductFlavors import tools.forma.validation.Validator import tools.forma.validation.validator @@ -18,6 +20,12 @@ class AndroidLibraryFeatureConfiguration( val viewBinding: Boolean = false, /** Enable Jetpack Compose for this Android library target. */ val compose: Boolean = false, + /** + * Product flavors for this library (optional; F-115 / NiA F27). + * Empty = unflavored (default). Same [FormaProductFlavor] model as `androidBinary`; + * APK-only fields are ignored on library apply. + */ + val productFlavors: List = emptyList(), val selfValidator: Validator = validator(LibraryTargetTemplate) ) @@ -49,6 +57,9 @@ fun androidLibraryFeatureDefinition( sourceSets.getByName("androidTest").java.srcDir("src/androidTest/kotlin") buildTypes.applyFrom(feature.buildConfiguration) + // Product flavors after build types (empty = unflavored library). + // Also registers src//kotlin on each flavor source set. + applyProductFlavors(feature.productFlavors) compileOptions.applyFrom(formaConfiguration) applyCoreLibraryDesugaring(project, formaConfiguration) diff --git a/plugins/android/src/main/java/tools/forma/android/utils/FormaProductFlavor.kt b/plugins/android/src/main/java/tools/forma/android/utils/FormaProductFlavor.kt index 18ce00d2..72150cf2 100644 --- a/plugins/android/src/main/java/tools/forma/android/utils/FormaProductFlavor.kt +++ b/plugins/android/src/main/java/tools/forma/android/utils/FormaProductFlavor.kt @@ -2,24 +2,31 @@ package tools.forma.android.utils import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.dsl.ApplicationProductFlavor +import com.android.build.api.dsl.LibraryExtension import com.android.build.api.dsl.ProductFlavor /** - * Typed product-flavor identity for `androidBinary` (F-115 / NiA F7 dogfood). + * Typed product-flavor identity for Forma Android targets (F-115 / NiA F7 + F27). * - * **Binary-only (v1):** product flavors are composition-root config — same placement - * rationale as [FormaSigningConfig] (F-097) and versionCode/Name (F-092). They are - * **not** stuffed into [BuildConfiguration] (build-types only) and are **not** a - * library/impl shopping API. + * **Same model for binary and library:** [FormaProductFlavor] is the one global way + * to declare flavor dimensions. Composition-root APKs use it on `androidBinary`; + * library targets that go through [tools.forma.android.feature.androidLibraryFeatureDefinition] + * (e.g. `androidUtil`) accept the same list. * - * Multi-module flavor dimensions (library `productFlavors` + `demoImplementation` - * edges) stay out of v1: AGP accepts unflavored library deps against a flavored - * application. Document gaps rather than invent dual happy paths. + * Libraries **ignore** APK-only fields ([applicationIdSuffix], [versionNameSuffix]) — + * [applyFormaProductFlavor] only writes those onto [ApplicationProductFlavor]. + * [BuildConfiguration] stays build-types only (flavors are not stuffed into it). + * + * Empty list (default) = unflavored target so existing samples stay unchanged. + * Flavor-scoped external deps use `NamedDependency.forProductFlavor` / + * `PlatformDependency.forProductFlavor` → AGP `prodImplementation` etc. + * (see `docs/CALL-SITE-SURFACE.md`). * * @param name flavor name (`demo`, `prod`, …) * @param dimension flavor dimension name (`contentType`, …) - * @param applicationIdSuffix optional APK id suffix (e.g. `".demo"`); null = none - * @param versionNameSuffix optional versionName suffix; null = none + * @param applicationIdSuffix optional APK id suffix (e.g. `".demo"`); null = none; + * ignored on library targets + * @param versionNameSuffix optional versionName suffix; null = none; ignored on libraries * @param matchingFallbacks optional AGP matchingFallbacks for this flavor * @param manifestPlaceholders extra placeholders merged onto this flavor * @param buildConfigFields optional BuildConfig field map (name → value expression) @@ -55,8 +62,7 @@ data class BuildConfigField( /** * Pure plan for AGP apply: ordered unique dimensions + flavors. * - * @throws IllegalArgumentException on blank names, duplicate flavor names, or - * empty non-null lists that would create a half-configured dimension set + * @throws IllegalArgumentException on blank names or duplicate flavor names */ fun resolveProductFlavorPlan( flavors: List, @@ -67,7 +73,7 @@ fun resolveProductFlavorPlan( flavors.forEach { flavor -> require(flavor.name !in names) { "Duplicate product flavor name '${flavor.name}' " + - "(each name may appear once on androidBinary)" + "(each name may appear once per target)" } names += flavor.name dimensions += flavor.dimension @@ -104,6 +110,27 @@ fun ApplicationExtension.applyProductFlavors(plan: ProductFlavorPlan) { } } +/** + * Applies [ProductFlavorPlan] onto AGP [LibraryExtension] flavor containers and + * registers `src//kotlin` on each flavor source set. + * + * No-op when the plan is empty (default library call sites). Does **not** require + * [ApplicationProductFlavor] fields — APK-only attrs are skipped by + * [applyFormaProductFlavor]. + */ +fun LibraryExtension.applyProductFlavors(plan: ProductFlavorPlan) { + if (plan.flavors.isEmpty()) return + flavorDimensions.clear() + flavorDimensions.addAll(plan.dimensions) + plan.flavors.forEach { forma -> + val existing = productFlavors.findByName(forma.name) + val flavor = existing ?: productFlavors.create(forma.name) + applyFormaProductFlavor(flavor, forma) + // Kotlin sources under src//kotlin (parity with main/test wiring). + sourceSets.maybeCreate(forma.name).java.srcDir("src/${forma.name}/kotlin") + } +} + /** Writes one [FormaProductFlavor] onto an AGP [ProductFlavor] / application flavor. */ fun applyFormaProductFlavor(target: ProductFlavor, forma: FormaProductFlavor) { target.dimension = forma.dimension @@ -129,3 +156,11 @@ fun applyFormaProductFlavor(target: ProductFlavor, forma: FormaProductFlavor) { fun ApplicationExtension.applyProductFlavors(flavors: List) { applyProductFlavors(resolveProductFlavorPlan(flavors)) } + +/** + * Convenience: resolve + apply in one step for library feature definition. + * Empty [flavors] is a no-op (keeps unflavored libraries working). + */ +fun LibraryExtension.applyProductFlavors(flavors: List) { + applyProductFlavors(resolveProductFlavorPlan(flavors)) +} diff --git a/plugins/android/src/test/kotlin/tools/forma/android/utils/FormaProductFlavorTest.kt b/plugins/android/src/test/kotlin/tools/forma/android/utils/FormaProductFlavorTest.kt index 1e44c99e..e15d7ee7 100644 --- a/plugins/android/src/test/kotlin/tools/forma/android/utils/FormaProductFlavorTest.kt +++ b/plugins/android/src/test/kotlin/tools/forma/android/utils/FormaProductFlavorTest.kt @@ -6,8 +6,8 @@ import kotlin.test.assertFailsWith import kotlin.test.assertTrue /** - * Pure unit coverage for binary product-flavor model (F-115 / NiA F7). - * No Gradle Project / AGP containers. + * Pure unit coverage for product-flavor model (F-115 / NiA F7 + F27 library reuse). + * No Gradle Project / AGP containers — plan resolution is shared by binary and library. */ class FormaProductFlavorTest { @@ -109,4 +109,20 @@ class FormaProductFlavorTest { plan.flavors[0].buildConfigFields["NIA_CONTENT_TYPE"], ) } + + @Test + fun `library plan reuses same resolve as binary — APK-only fields stay on model`() { + // Libraries pass the same FormaProductFlavor list; applyFormaProductFlavor + // skips applicationIdSuffix on non-ApplicationProductFlavor. Plan is identical. + val libraryCallSite = listOf( + FormaProductFlavor(name = "demo", dimension = "contentType", applicationIdSuffix = ".demo"), + FormaProductFlavor(name = "prod", dimension = "contentType"), + ) + val plan = resolveProductFlavorPlan(libraryCallSite) + assertEquals(ProductFlavorPlan.EMPTY, resolveProductFlavorPlan(emptyList())) + assertEquals(listOf("contentType"), plan.dimensions) + assertEquals(listOf("demo", "prod"), plan.flavors.map { it.name }) + // Model still carries APK suffix (binary may share the list); library apply ignores it. + assertEquals(".demo", plan.flavors[0].applicationIdSuffix) + } } diff --git a/plugins/deps/src/main/java/dependencies.kt b/plugins/deps/src/main/java/dependencies.kt index fdec8620..072f4c9c 100644 --- a/plugins/deps/src/main/java/dependencies.kt +++ b/plugins/deps/src/main/java/dependencies.kt @@ -24,6 +24,7 @@ import tools.forma.deps.core.TargetDependency import tools.forma.deps.core.TargetSpec import tools.forma.deps.core.USE_FEATURE_STUBS_FLAG import tools.forma.deps.core.featureImplementationPair +import tools.forma.deps.core.productFlavorImplementation import tools.forma.target.FormaTarget /** @@ -233,6 +234,52 @@ fun NamedDependency.whenFlag(flag: String, enabled: Boolean = true): NamedDepend } ) +/** + * Map every name spec onto AGP `{flavor}Implementation` (F-115 / NiA F27). + * + * Preserves [NameSpec.transitive] and feature-flag gating. Compose with [plus]: + * + * ```kotlin + * dependencies = deps(target(":core:model:library")) + + * transitiveDeps("com.google.firebase:firebase-analytics").forProductFlavor("prod") + + * transitivePlatform("com.google.firebase:firebase-bom:33.16.0").forProductFlavor("prod") + * ``` + * + * Requires matching [tools.forma.android.utils.FormaProductFlavor] on the library target + * so AGP creates the configuration. Do **not** scatter free-form config name strings. + */ +fun NamedDependency.forProductFlavor(flavor: String): NamedDependency { + val config = productFlavorImplementation(flavor) + return NamedDependency( + names.map { spec -> + NameSpec( + name = spec.name, + config = config, + transitive = spec.transitive, + featureFlag = spec.featureFlag, + featureFlagExpected = spec.featureFlagExpected, + ) + } + ) +} + +/** + * Map every platform (BOM) spec onto AGP `{flavor}Implementation` (F-115 / NiA F27). + * Preserves [PlatformSpec.transitive]. See [NamedDependency.forProductFlavor]. + */ +fun PlatformDependency.forProductFlavor(flavor: String): PlatformDependency { + val config = productFlavorImplementation(flavor) + return PlatformDependency( + names.map { spec -> + PlatformSpec( + name = spec.name, + config = config, + transitive = spec.transitive, + ) + } + ) +} + /** * Gate every target spec in this [TargetDependency] on project-global feature flag [flag] * (F-104 / GH #43). Same apply-time resolution as [NamedDependency.whenFlag]. diff --git a/plugins/deps/src/main/java/tools.forma/deps/core/ConfigurationType.kt b/plugins/deps/src/main/java/tools.forma/deps/core/ConfigurationType.kt index e39063bb..273794bd 100644 --- a/plugins/deps/src/main/java/tools.forma/deps/core/ConfigurationType.kt +++ b/plugins/deps/src/main/java/tools.forma/deps/core/ConfigurationType.kt @@ -30,6 +30,22 @@ object Ksp : ConfigurationType { @JvmInline value class CustomConfiguration(override val name: String) : ConfigurationType +/** + * AGP product-flavor implementation configuration name (`prodImplementation`, …). + * + * Pure helper for F-115 / NiA F27 flavor-scoped external deps. Prefer + * [NamedDependency.forProductFlavor] / [PlatformDependency.forProductFlavor] at + * call sites rather than free-form configuration strings. + * + * @throws IllegalArgumentException when [flavor] is blank + */ +fun productFlavorImplementation(flavor: String): CustomConfiguration { + require(flavor.isNotBlank()) { + "product flavor name for configuration must not be blank" + } + return CustomConfiguration("${flavor}Implementation") +} + sealed class DepSpec(val config: ConfigurationType) /** diff --git a/plugins/deps/src/test/kotlin/tools/forma/deps/core/DepsModelAndPluginHappyPathTest.kt b/plugins/deps/src/test/kotlin/tools/forma/deps/core/DepsModelAndPluginHappyPathTest.kt index 77a68bb5..88f971c3 100644 --- a/plugins/deps/src/test/kotlin/tools/forma/deps/core/DepsModelAndPluginHappyPathTest.kt +++ b/plugins/deps/src/test/kotlin/tools/forma/deps/core/DepsModelAndPluginHappyPathTest.kt @@ -6,12 +6,14 @@ import kotlin.test.assertEquals import kotlin.test.assertSame import kotlin.test.assertTrue import dep +import forProductFlavor import ksp import plus import tools.forma.core.target.targetType import transitiveDep import transitiveDeps import tools.forma.deps.core.CompileOnly +import tools.forma.deps.core.CustomConfiguration import tools.forma.deps.core.EmptyDependency import tools.forma.deps.core.FileDependency import tools.forma.deps.core.FileSpec @@ -24,6 +26,7 @@ import tools.forma.deps.core.PlatformDependency import tools.forma.deps.core.PlatformSpec import tools.forma.deps.core.TargetPluginRegistry import tools.forma.deps.core.deriveTargetType +import tools.forma.deps.core.productFlavorImplementation import tools.forma.deps.core.registerTargetPlugin import tools.forma.deps.core.targetPlugin import tools.forma.deps.core.DefaultTargetPluginRegistry @@ -92,6 +95,71 @@ class DepsModelAndPluginHappyPathTest { assertTrue(mixed.dependency.any { it is NameSpec }) } + @Test + fun `productFlavorImplementation maps to AGP flavor Implementation config`() { + assertEquals("prodImplementation", productFlavorImplementation("prod").name) + assertEquals("demoImplementation", productFlavorImplementation("demo").name) + assertFailsWithBlankFlavor() + } + + private fun assertFailsWithBlankFlavor() { + try { + productFlavorImplementation(" ") + throw AssertionError("expected blank flavor to fail") + } catch (e: IllegalArgumentException) { + assertTrue(e.message!!.contains("blank")) + } + } + + @Test + fun `NamedDependency forProductFlavor remaps config and preserves transitive + flags`() { + val flagged = + NamedDependency( + listOf( + NameSpec( + name = "com.google.firebase:firebase-analytics", + config = Implementation, + transitive = true, + featureFlag = "useFirebase", + featureFlagExpected = true, + ), + ), + ).forProductFlavor("prod") + val spec = flagged.names.single() + assertEquals("com.google.firebase:firebase-analytics", spec.name) + assertEquals(CustomConfiguration("prodImplementation"), spec.config) + assertEquals("prodImplementation", spec.config.name) + assertTrue(spec.transitive) + assertEquals("useFirebase", spec.featureFlag) + assertEquals(true, spec.featureFlagExpected) + } + + @Test + fun `PlatformDependency forProductFlavor remaps BOM config and preserves transitive`() { + val bom = + PlatformDependency( + listOf(PlatformSpec("com.google.firebase:firebase-bom:33.16.0", Implementation, transitive = true)), + ).forProductFlavor("prod") + val spec = bom.names.single() + assertEquals("com.google.firebase:firebase-bom:33.16.0", spec.name) + assertEquals(CustomConfiguration("prodImplementation"), spec.config) + assertTrue(spec.transitive) + } + + @Test + fun `forProductFlavor composes with plus for NiA prod stack`() { + val stack = + transitiveDeps("com.google.firebase:firebase-analytics").forProductFlavor("prod") + + PlatformDependency( + listOf(PlatformSpec("com.google.firebase:firebase-bom:33.16.0", Implementation)), + ).forProductFlavor("prod") + assertEquals(1, stack.names.size) + assertEquals(1, stack.platforms.size) + assertEquals("prodImplementation", stack.names.single().config.name) + assertEquals("prodImplementation", stack.platforms.single().config.name) + assertTrue(stack.names.single().transitive) + } + @Test fun `dependency model constructors preserve specs`() { val name = NameSpec("g:a:1", Implementation, transitive = true)