From b33327832ac8967c9bf1f2978189a3319bd601e9 Mon Sep 17 00:00:00 2001 From: Claw Date: Fri, 24 Jul 2026 21:48:18 -0700 Subject: [PATCH] F-097: first-class signing configs on androidBinary --- TICKETS.md | 2 +- application/binary/build.gradle.kts | 38 +++++ application/binary/demo-release.keystore | Bin 0 -> 2762 bytes docs/CALL-SITE-SURFACE.md | 55 ++++++- docs/GETTING-STARTED.md | 5 + docs/PROGRESS.md | 20 +++ docs/PROJECT-CONFIGURATION.md | 3 + plugins/android/build.gradle.kts | 7 + .../android/src/main/java/androidBinary.kt | 30 +++- .../forma/android/feature/AndroidBinary.kt | 21 +++ .../forma/android/utils/FormaSigningConfig.kt | 154 ++++++++++++++++++ .../java/tools/forma/android/utils/android.kt | 8 + .../android/utils/FormaSigningConfigTest.kt | 116 +++++++++++++ 13 files changed, 449 insertions(+), 10 deletions(-) create mode 100644 application/binary/demo-release.keystore create mode 100644 plugins/android/src/main/java/tools/forma/android/utils/FormaSigningConfig.kt create mode 100644 plugins/android/src/test/kotlin/tools/forma/android/utils/FormaSigningConfigTest.kt diff --git a/TICKETS.md b/TICKETS.md index ecea4ce8..c6b2fef7 100644 --- a/TICKETS.md +++ b/TICKETS.md @@ -137,7 +137,7 @@ worker (ops); `v2`→`master` (explicit git promote only). **F-094** stays `bloc | ID | Status | Title | Notes | |----|--------|-------|-------| | F-096 | done | `String.transitiveDep` catalog parity | GH **#77** — `val String.transitiveDep` → `transitiveDeps(this)` next to `String.dep` / `String.ksp`; unit tests + `docs/DEPS-CATALOG.md` API/tip. | -| F-097 | todo | Finish build types: signing configs | GH **#51** — checklist mostly done (DSL/`BuildConfiguration`/`BuildType` lambdas, proguard, build fields). **Remaining:** first-class **signing configs** on `androidBinary` (one global way; attrs on binary only — not per-library shopping). Docs + sample/example if safe dummy keystore pattern; no secrets in repo. | +| F-097 | done | Finish build types: signing configs | GH **#51** — `FormaSigningConfig` + `androidBinary(signingConfigs, buildTypeSigning)` wired to AGP application signing only; dummy `application/binary/demo-release.keystore`; CALL-SITE-SURFACE § APK signing; unit tests on `:android`. | | F-098 | todo | Core library desugaring (Java 8+ APIs) | GH **#103** — project-global desugar path for lower `minSdk` that needs `coreLibraryDesugaring` (Android Java 8+ library desugaring). One way on `androidProjectConfiguration` / settings; wire AGP `compileOptions.isCoreLibraryDesugaringEnabled` + desugar dep; docs ENV/GETTING-STARTED; verify on sample or progressive example. | | F-099 | todo | Target-feature configuration options | GH **#126** — design+thin slice: conditional / flagged deps and feature toggles (e.g. AP vs reflect DI) without dual happy paths. Prefer **type/rule or single global configuration** over call-site plugin shopping. Outcome may be design doc + smallest useful API; reject free-form per-module plugin lists. | | F-100 | todo | Gradle project on buildscript classpath | GH **#111** — investigate allowing a Gradle **project** (composite/included build) as `extraPlugins` / buildscript classpath source alongside Maven coords. Design note first if API shape unclear; keep `extraPlugins` = classpath only (F-082). Implement only if clean with AGP 9 / Gradle 9. | diff --git a/application/binary/build.gradle.kts b/application/binary/build.gradle.kts index bfea9dcd..710cac8c 100644 --- a/application/binary/build.gradle.kts +++ b/application/binary/build.gradle.kts @@ -1,3 +1,6 @@ +import tools.forma.android.utils.BuildConfiguration +import tools.forma.android.utils.FormaSigningConfig + androidBinary( packageName = "tools.forma.sample.app", owner = Teams.core, @@ -5,6 +8,41 @@ androidBinary( versionName = "0.0.1", // Compose enabled so binary can host Compose content from compose-widget deps. compose = true, + // F-097 / GH #51: first-class signing on the APK root only. + // demo-release.keystore is a committed *dummy* store (password "android") for docs/CI — + // never commit production keystores or secrets. Real apps: read path/passwords from + // env or project properties with empty defaults so assembleDebug stays green. + signingConfigs = mapOf( + "demoRelease" to FormaSigningConfig( + storeFile = file("demo-release.keystore"), + storePassword = "android", + keyAlias = "androiddebugkey", + keyPassword = "android", + ), + // Production sketch (not active — empty passwords would fail release signing): + // "release" to FormaSigningConfig( + // storeFile = file( + // findProperty("forma.storeFile") as String? + // ?: System.getenv("FORMA_STORE_FILE") + // ?: "missing-release.keystore" + // ), + // storePassword = System.getenv("FORMA_STORE_PASSWORD") ?: "", + // keyAlias = System.getenv("FORMA_KEY_ALIAS") ?: "upload", + // keyPassword = System.getenv("FORMA_KEY_PASSWORD") ?: "", + // ), + ), + // Wire release → dummy store so assembleRelease can succeed without secrets. + // debug keeps AGP's built-in debug signing (assembleDebug needs no custom config). + buildTypeSigning = mapOf( + "release" to "demoRelease", + ), + buildConfiguration = BuildConfiguration( + buildTypes = mapOf( + "release" to { + isMinifyEnabled = false + }, + ), + ), dependencies = deps( target(":root-app"), diff --git a/application/binary/demo-release.keystore b/application/binary/demo-release.keystore new file mode 100644 index 0000000000000000000000000000000000000000..c1dc64939a01566ad70efca1031f6ad5ae216ef3 GIT binary patch literal 2762 zcma)8S5y-U5=}xvNoZ0-3q_PF1Oie*M-(I!X^H|Gst8CCM2d=mP^5*9fDn4whY_ht zi}XhoP)ejKO%!R;16lU$9zFZD`!VOv+&gzZ?-?Wx+64mAA#qS2dM4>e!^k}rAOnzy zgJQursQoEzi^M_5|0O}Hz&J?dDP4YA#q_ZMxR{|p5D^E_IVClauD=}+7Njjw_0PzR zlmT+IeNvI7WV+2rknXV7;7rBsW*85Cww{fCH ze|iy4m`lt}S4S%-8x+6>Y~E^E0&t3YNJq9W^JVEI)p?n-mo@f@9y4QlDjpaWja)pu z)aGGxoWRZ7Rt9r1aHX zI4!iGYn^m;NRIbbFS*R5sb_z)qW<-pS`z0XGKTBkb$rRnVIfV0+!YJB7LHgAD~9A# zWQN@cD811HDbmj1?f2V1?w~^gCOnrT)dJ=Rx#aS6a82EHZsSs5N5mDFh>C2IaAoLA z^^8x~1ZTy{(mkx?RH-+zqXHx|>Ln!_-+IZW4d6Qjvq7AcdmiPSxg`HrQAz0@w`3%5 zP1k1Ev1BFQhXsd=oC(}6^rV&?`Vi#vw?;cG12eZtT}Ypm#`=4ZR@Qx^RypS!*iDtAqX&5;YFOSjj&mnP;>x2kPv5rI?9=2VSiS~Qz~p6KUWmp5duMocy&w@tTc{PI)+l2))` zc#DoZopds4YL= z^(esZv54PYLs15OcFa2wgAMHj=BkbX-NPlUFFNJEv2FF8MI6t~+%@&tD`MEJ3)uoS z^(s*|>9!y3d?~`^5EYws&9Bm-)JILW9uo$g@LMyt zj$sUZo;2ztS`s`FBB<$2cGS?7OsOxll)DvIoyUyqBsm!7-k=c_eS$?!=wJDlo%JyF zuslhVh*1D5dD;NSUqsf(a6fA+)zpI+xG+e#vV9$wsJLOQ-3xTH;i(ER*t6@rlGdv7 zY&oXcvEoW7alL3c1fNhveMg-OT;0>os4#n-e1bqnqsX3JpTpM#zVXk6S%Pe^z{V#I z-xk36T4RZ#22Fyi!nLfS6g4ZozPJJm(cQHESo%@24s6{jp!nHA48@nDQPiVvqnWGK z;Gsx+IA52v$1L7w!(I>%A#!VB8BD9;)V|B-qk4XH_EpwY#lzmJ8H5@Na89(@DuQHV zq2t7Z6*!Yy8FNu4Bf~5a_Q`prow~F%6%{nRa}?1_hQ}`Lw=sHzPR*x8Wu#iyk=D{x zx)m&t7k`C7n=# zB{N*dy%1irY_HBzJ}f!Y*xh}hfLFe;*>g1U(Ra)Joz&bSv3E7dsNrN2#E(@rJDI0l z+x+T725;N9N6#i;x<`E5U$DNH)$c>`D7RUeQ2 z-~y(+STn!V5pDVlF3Ma9Xg@j#ez+jB%j~}A{qAr~yXhY-7kK2Kb1X|&*!a(eK$qsT zldpvb(iq3HUK=@F&`|=cV52(q`7xanC1MK^XGuT8wUo%Ce>FNAISEjmViXIae=-$J znqv4O;~TGg>FWl~4E6UKvmVXf{S<2)6Ylin>M(cg+wzGtLAz=r!7TPc8F(7tgy)0(h#`pkOvL!@Ol$&UajK?q(t^X* zP1I^u9+pgO)^xW~(ky_7Ok>1bCJ0Y?BQNgvH>mN2S{wJgd4Ex;#>Kx}2r4LkVfc$x z*jE|1r4DvN^m<+>|Ehw*Ln=BZN#W2kZxaw5Q4IewG+8e&SYyM3uET80A!w|QAil75 zB7f$RQ)MQfH3&oaY8@; zK_?===Sl7aYoVG;>LqkZ#ryQV^Qy|_R!=J9KJ13Z$vQKDcrzaqBM#b#m`At6X^yeV zu1Dg1O9`fi6Tt#@kYV8mxAU&NVh8K=c;-|v3eHbtIj@J+t4e+i&Gc<~-_kr&F&==HE^{fAkGK`EO8Wf( literal 0 HcmV?d00001 diff --git a/docs/CALL-SITE-SURFACE.md b/docs/CALL-SITE-SURFACE.md index 7a862dba..465b64bf 100644 --- a/docs/CALL-SITE-SURFACE.md +++ b/docs/CALL-SITE-SURFACE.md @@ -30,7 +30,7 @@ All return **`Unit`**. | `api` | `api` | `packageName`, `dependencies` | JVM contracts; no Android UI | | `impl` | `impl` | deps, **`viewBinding`**, **`compose`**, test runners, `buildConfiguration` | Feature impl; no → other `impl` | | `androidApp` | `app` | deps, **`compose`**, `buildConfiguration`, … | Composition **library** (not APK) — **no** `versionCode`/`Name` | -| `androidBinary` | `binary` | **`versionCode`/`versionName` (required)**, deps, **`compose`**, … | APK composition root; version identity is **per-binary only** (F-092) | +| `androidBinary` | `binary` | **`versionCode`/`versionName` (required)**, deps, **`compose`**, **`signingConfigs` / `buildTypeSigning`**, … | APK composition root; version + signing are **per-binary only** (F-092 / F-097) | | `library` | `library` | deps (pure JVM inside Android plugin) | Distinct from removed `androidLibrary` | | `util` | `util` | deps | JVM helpers | | `androidUtil` | `android-util` | deps, **`compose`** | Android helpers, no res content | @@ -92,6 +92,59 @@ override ladder — each binary declares both attrs explicitly. **Gradle limit:** `versionCode`/`versionName` apply to application modules. Putting them on library-shaped `androidApp` would not produce a second APK version surface. +## APK signing (F-097 / GH #51) + +| Concern | Owner | Notes | +|---------|--------|--------| +| Named signing configs | **`androidBinary` call site only** (`signingConfigs: Map`) | Wired to AGP `ApplicationExtension.signingConfigs` | +| Build type → signing | **`androidBinary` `buildTypeSigning`** (build type name → config name) | Applied after both containers exist | +| Library / `androidApp` signing API | **None** | No per-`impl` / `uiLibrary` shopping; APK signing is composition-root only | +| Raw `android { signingConfigs { … } }` | **Rejected as happy path** | Escape hatch only; do not teach dual paths | + +**Why binary-only:** same rationale as version identity — only `com.android.application` +materializes installable APK signing. Library shells (`androidApp`, `impl`, …) stay +free of release-keystore concerns. + +**Call-site shape:** + +```kotlin +import tools.forma.android.utils.BuildConfiguration +import tools.forma.android.utils.FormaSigningConfig + +androidBinary( + packageName = "com.example.app", + versionCode = 1, + versionName = "0.1.0", + signingConfigs = mapOf( + "demoRelease" to FormaSigningConfig( + storeFile = file("demo-release.keystore"), + storePassword = "android", // dummy / CI only — never real secrets in git + keyAlias = "androiddebugkey", + keyPassword = "android", + ), + ), + buildTypeSigning = mapOf( + "release" to "demoRelease", + // debug → AGP default debug signing when omitted + ), + buildConfiguration = BuildConfiguration( + buildTypes = mapOf( + "release" to { isMinifyEnabled = false }, + ), + ), + dependencies = deps(/* … */), +) +``` + +**Secrets:** commit only clearly-fake demo keystores (see sample +`application/binary/demo-release.keystore`). Production paths/passwords come from +env or `gradle.properties` (local, gitignored) — never from the repo. `assembleDebug` +must stay green without secrets (omit `buildTypeSigning` for `debug` or leave AGP +default). + +**Not in scope:** product flavors, Play App Signing backend integration, library +AAR signing APIs. + ## AGP BuildFeatures (F-091 / GH #88) AGP `BuildFeatures` flags are **project-global by default** (all **off**), with diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 84c21ca1..82f0a7da 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -236,6 +236,9 @@ androidBinary( // F-092: version identity is per-binary (not androidProjectConfiguration) versionCode = 1, versionName = "0.1.0", + // F-097: optional APK signing — binary only; see CALL-SITE-SURFACE § APK signing + // signingConfigs = mapOf("demoRelease" to FormaSigningConfig(...)), + // buildTypeSigning = mapOf("release" to "demoRelease"), dependencies = deps( target(":root-app"), target(":feature:hello:api"), @@ -246,6 +249,8 @@ androidBinary( Add another `androidBinary` module if you ship a second APK — give it its own `versionCode` / `versionName`. Do not put version attrs on `androidApp` (library shell). +Signing configs are likewise **binary-only** (F-097); debug builds need no custom +signing. Full example: sample `application/binary/`. **`root-app/build.gradle.kts`**: diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 891122f2..8ac53573 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -2,6 +2,26 @@ Newest entries first. +## 2026-07-24 — F-097: signing configs on androidBinary (GH #51) + +- **Ticket:** F-097 → `done` (GH #51 remaining checkbox) +- **Branch:** `forma/F-097-signing-configs` (from `origin/v2`) +- **Skills/modes:** Grok Build `--mode full` (design/plan + implement); Hermes finish path after CLI timeout on app assemble +- **Design:** binary-only attrs (mirror F-092). Keep `BuildConfiguration` = build-types only. New `FormaSigningConfig` + `signingConfigs` / `buildTypeSigning` on `androidBinary` → AGP application containers only. Name→name bridge because `BuildType.() -> Unit` cannot see application `signingConfig`. +- **Code:** + - `plugins/android/.../FormaSigningConfig.kt` — model, `toAppliedFields`, `resolveBuildTypeSigningPairs`, `applySigningConfigs`, `applyBuildTypeSigning` + - `AndroidBinaryFeatureConfiguration` + `androidBinary` public attrs; apply order in `androidBinaryFeatureDefinition` + - `:android` unit tests (7) via `kotlin("test")` + JUnit Platform + - Sample: committed dummy `application/binary/demo-release.keystore` (password `android`); `release` → `demoRelease`; minify off on release +- **Docs:** CALL-SITE-SURFACE § APK signing; PROJECT-CONFIGURATION “does not”; GETTING-STARTED pointer; KDoc on binary +- **Verify (real host, `source scripts/env-mac.sh`):** + - `plugins/`: `./gradlew :android:cleanTest :android:test` → **BUILD SUCCESSFUL** (7 tests) + - `plugins/`: `./gradlew test jacocoHappyPathCoverageVerification` → **BUILD SUCCESSFUL** + - `application/`: `./gradlew :binary:clean :binary:assembleDebug :binary:assembleRelease` → **BUILD SUCCESSFUL** (2m8s) +- **Commits/PRs:** this branch; Hermes PR/merge; close GH #51 when merged +- **Blockers:** none (F-094 Portal still human-blocked) +- **Next step:** F-098 (core library desugaring) + ## 2026-07-24 — F-096: String.transitiveDep catalog parity - **Ticket:** F-096 → `done` (GH #77) diff --git a/docs/PROJECT-CONFIGURATION.md b/docs/PROJECT-CONFIGURATION.md index 6326926a..6e170070 100644 --- a/docs/PROJECT-CONFIGURATION.md +++ b/docs/PROJECT-CONFIGURATION.md @@ -110,6 +110,9 @@ resolved values so AGP platform defaults cannot silently turn features on. **required** attrs on each `androidBinary { … }` call site. `androidApp` is a library composition shell and does not take version attrs. See [`CALL-SITE-SURFACE.md`](CALL-SITE-SURFACE.md) § APK version identity. +- **APK signing configs** — not project-global (F-097 / GH #51). Named + `FormaSigningConfig` entries and `buildTypeSigning` live on each `androidBinary` + only. See [`CALL-SITE-SURFACE.md`](CALL-SITE-SURFACE.md) § APK signing. There is no per-module `withPlugin`, no free-form `plugins =` lists, and no second configuration path. diff --git a/plugins/android/build.gradle.kts b/plugins/android/build.gradle.kts index 543d51f9..794d522e 100644 --- a/plugins/android/build.gradle.kts +++ b/plugins/android/build.gradle.kts @@ -27,6 +27,13 @@ dependencies { implementation(project(":deps")) // F-021: core restriction + target types (pure engine); Android owns concrete type instances + matrix implementation(project(":core")) + + // F-097: pure model tests (FormaSigningConfig / buildTypeSigning resolver) + testImplementation(kotlin("test")) +} + +tasks.test { + useJUnitPlatform() } tasks.named("publishPlugins") { diff --git a/plugins/android/src/main/java/androidBinary.kt b/plugins/android/src/main/java/androidBinary.kt index 3018fbb2..5c47221a 100644 --- a/plugins/android/src/main/java/androidBinary.kt +++ b/plugins/android/src/main/java/androidBinary.kt @@ -5,6 +5,7 @@ import tools.forma.android.feature.applyFeatures import tools.forma.android.target.AndroidTargetRegistry import tools.forma.android.target.AndroidTargetTypes import tools.forma.android.utils.BuildConfiguration +import tools.forma.android.utils.FormaSigningConfig import tools.forma.android.validation.disallowResources import tools.forma.deps.core.FormaDependency import tools.forma.deps.core.applyDependencies @@ -30,12 +31,21 @@ import tools.forma.validation.validate * only materialize `defaultConfig.versionCode`/`versionName` on * `com.android.application`). * + * **APK signing (F-097 / GH #51):** [signingConfigs] and [buildTypeSigning] are + * **binary-only** attrs. Forma registers named configs on AGP + * `ApplicationExtension.signingConfigs`, then assigns each build type via + * [buildTypeSigning] (build type name → signing config name). Libraries do not + * take a signing API. Do not use raw module-level `android { signingConfigs }` as + * the supported path. + * * @param packageName Application package name / `applicationId` + namespace * @param owner owner of the target, team responsible for maintenance * @param versionCode Android `versionCode` for this APK (required; per-binary) * @param versionName Android `versionName` for this APK (required; per-binary) * @param dependencies list of external and project dependencies for the target - * @param buildConfiguration Android Gradle Plugin configuration DSL + * @param buildConfiguration Android Gradle Plugin build-type configuration DSL + * @param signingConfigs named [FormaSigningConfig] entries for this APK (optional) + * @param buildTypeSigning map of build type name → signing config name (optional) * @param testInstrumentationRunner class name used for instrumentation tests execution * @param consumerMinificationFiles Proguard/R8 minification files list * @param manifestPlaceholders placeholders to be injected in manifest @@ -47,6 +57,8 @@ fun Project.androidBinary( versionName: String, dependencies: FormaDependency = emptyDependency(), buildConfiguration: BuildConfiguration = BuildConfiguration(), + signingConfigs: Map = emptyMap(), + buildTypeSigning: Map = emptyMap(), testInstrumentationRunner: String = androidJunitRunner, consumerMinificationFiles: Set = emptySet(), manifestPlaceholders: Map = emptyMap(), @@ -61,13 +73,15 @@ fun Project.androidBinary( // applicationId / namespace; composition root often has only a manifest. registerFormaLayout(packageName, requirePackageSourceDir = false) val binaryFeatureConfiguration = AndroidBinaryFeatureConfiguration( - packageName, - versionCode, - versionName, - buildConfiguration, - testInstrumentationRunner, - consumerMinificationFiles, - manifestPlaceholders, + packageName = packageName, + versionCode = versionCode, + versionName = versionName, + buildConfiguration = buildConfiguration, + testInstrumentationRunnerClass = testInstrumentationRunner, + consumerMinificationFiles = consumerMinificationFiles, + manifestPlaceholders = manifestPlaceholders, + signingConfigs = signingConfigs, + buildTypeSigning = buildTypeSigning, selfValidator = selfV, compose = compose, ) 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 5c1a11a8..f5848ce8 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 @@ -5,7 +5,10 @@ package tools.forma.android.feature import com.android.build.api.dsl.ApplicationExtension import tools.forma.android.target.BinaryTargetTemplate import tools.forma.android.utils.BuildConfiguration +import tools.forma.android.utils.FormaSigningConfig +import tools.forma.android.utils.applyBuildTypeSigning import tools.forma.android.utils.applyFrom +import tools.forma.android.utils.applySigningConfigs import tools.forma.validation.Validator import tools.forma.validation.validator @@ -14,6 +17,9 @@ import tools.forma.validation.validator * * [versionCode] / [versionName] are **per-binary** (F-092) — never read from * project-global [tools.forma.config.AndroidProjectSettings]. + * + * [signingConfigs] / [buildTypeSigning] are **per-binary** (F-097 / GH #51) — + * APK signing identity lives only on the composition root, not on library shells. */ data class AndroidBinaryFeatureConfiguration( val packageName: String, @@ -23,6 +29,13 @@ data class AndroidBinaryFeatureConfiguration( val testInstrumentationRunnerClass: String, val consumerMinificationFiles: Set, val manifestPlaceholders: Map = emptyMap(), + /** Named signing configs applied to AGP `ApplicationExtension.signingConfigs`. */ + val signingConfigs: Map = emptyMap(), + /** + * Build type name → signing config name. Applied after both containers exist + * so call sites do not need AGP `SigningConfig` references inside build-type lambdas. + */ + val buildTypeSigning: Map = emptyMap(), /** Enable Jetpack Compose for the application (APK) target. */ val compose: Boolean = false, val selfValidator: Validator = validator(BinaryTargetTemplate) @@ -50,7 +63,15 @@ fun androidBinaryFeatureDefinition( configuration.manifestPlaceholders ) + // Signing first so build types can rely on registered names; bridge map + // assigns ApplicationBuildType.signingConfig after buildTypes.applyFrom. + signingConfigs.applySigningConfigs(configuration.signingConfigs) buildTypes.applyFrom(configuration.buildConfiguration) + applyBuildTypeSigning( + buildTypes = buildTypes, + signingConfigs = signingConfigs, + buildTypeSigning = configuration.buildTypeSigning, + ) compileOptions.applyFrom(formaConfiguration) applyFormaBuildFeatures( diff --git a/plugins/android/src/main/java/tools/forma/android/utils/FormaSigningConfig.kt b/plugins/android/src/main/java/tools/forma/android/utils/FormaSigningConfig.kt new file mode 100644 index 00000000..6960e05c --- /dev/null +++ b/plugins/android/src/main/java/tools/forma/android/utils/FormaSigningConfig.kt @@ -0,0 +1,154 @@ +package tools.forma.android.utils + +import com.android.build.api.dsl.ApkSigningConfig +import com.android.build.api.dsl.ApplicationVariantDimension +import java.io.File +import org.gradle.api.NamedDomainObjectContainer + +/** + * Typed APK signing identity for `androidBinary` (F-097 / GH #51). + * + * Maps cleanly onto AGP [ApkSigningConfig]. **Binary-only:** release/APK signing is + * configured on the composition root, not on library targets (`impl`, `uiLibrary`, + * `androidApp` shell). Do not teach raw `android { signingConfigs { … } }` as the + * happy path. + * + * @param storeFile keystore file (use `project.file("…")` at the call site) + * @param storePassword keystore password (prefer env / project properties in real apps; + * never commit production secrets) + * @param keyAlias key alias inside the store + * @param keyPassword key password + * @param storeType optional store type (e.g. `"pkcs12"`); null leaves AGP default + * @param enableV1Signing optional APK Signature Scheme v1 flag + * @param enableV2Signing optional APK Signature Scheme v2 flag + * @param enableV3Signing optional APK Signature Scheme v3 flag + * @param enableV4Signing optional APK Signature Scheme v4 flag + */ +data class FormaSigningConfig( + val storeFile: File, + val storePassword: String, + val keyAlias: String, + val keyPassword: String, + val storeType: String? = null, + val enableV1Signing: Boolean? = null, + val enableV2Signing: Boolean? = null, + val enableV3Signing: Boolean? = null, + val enableV4Signing: Boolean? = null, +) + +/** + * Resolved field bag for unit tests and AGP apply. + * Optional flags are omitted from the bag when null so AGP defaults stay untouched. + */ +data class AppliedSigningFields( + val storeFile: File, + val storePassword: String, + val keyAlias: String, + val keyPassword: String, + val storeType: String? = null, + val enableV1Signing: Boolean? = null, + val enableV2Signing: Boolean? = null, + val enableV3Signing: Boolean? = null, + val enableV4Signing: Boolean? = null, +) + +/** Pure projection of [FormaSigningConfig] (no AGP types). */ +fun FormaSigningConfig.toAppliedFields(): AppliedSigningFields = + AppliedSigningFields( + storeFile = storeFile, + storePassword = storePassword, + keyAlias = keyAlias, + keyPassword = keyPassword, + storeType = storeType, + enableV1Signing = enableV1Signing, + enableV2Signing = enableV2Signing, + enableV3Signing = enableV3Signing, + enableV4Signing = enableV4Signing, + ) + +/** Writes [AppliedSigningFields] onto AGP [ApkSigningConfig]. */ +fun AppliedSigningFields.applyTo(target: ApkSigningConfig) { + target.storeFile = storeFile + target.storePassword = storePassword + target.keyAlias = keyAlias + target.keyPassword = keyPassword + storeType?.let { target.storeType = it } + enableV1Signing?.let { target.enableV1Signing = it } + enableV2Signing?.let { target.enableV2Signing = it } + enableV3Signing?.let { target.enableV3Signing = it } + enableV4Signing?.let { target.enableV4Signing = it } +} + +/** Convenience: [FormaSigningConfig] → AGP [ApkSigningConfig]. */ +fun FormaSigningConfig.applyTo(target: ApkSigningConfig) { + toAppliedFields().applyTo(target) +} + +/** + * Pure validation of build-type → signing-config name pairs. + * + * @return ordered pairs ready to assign + * @throws IllegalArgumentException when a referenced name is missing + */ +fun resolveBuildTypeSigningPairs( + buildTypeSigning: Map, + buildTypeNames: Set, + signingConfigNames: Set, +): List> { + if (buildTypeSigning.isEmpty()) return emptyList() + return buildTypeSigning.map { (buildTypeName, signingName) -> + require(buildTypeName in buildTypeNames) { + "buildTypeSigning references unknown build type '$buildTypeName' " + + "(known: ${buildTypeNames.sorted()})" + } + require(signingName in signingConfigNames) { + "buildTypeSigning maps '$buildTypeName' → unknown signing config '$signingName' " + + "(known: ${signingConfigNames.sorted()})" + } + buildTypeName to signingName + } +} + +/** + * Registers named signing configs on an AGP application [signingConfigs] container. + * + * Uses create-or-get semantics: creates when missing, otherwise updates in place + * (AGP already ships a `debug` config). + */ +@Suppress("UNCHECKED_CAST") +internal fun NamedDomainObjectContainer<*>.applySigningConfigs( + configs: Map, +) { + if (configs.isEmpty()) return + val container = this as NamedDomainObjectContainer + configs.forEach { (name, forma) -> + val signing = container.findByName(name) ?: container.create(name) + forma.applyTo(signing) + } +} + +/** + * Bridges build type name → signing config name after both containers exist. + * + * Preferred over `signingConfig = …` inside [BuildConfiguration] build-type lambdas: + * those lambdas are typed as [com.android.build.api.dsl.BuildType], which does not + * expose application [ApplicationVariantDimension.signingConfig]. + */ +@Suppress("UNCHECKED_CAST") +internal fun applyBuildTypeSigning( + buildTypes: NamedDomainObjectContainer<*>, + signingConfigs: NamedDomainObjectContainer<*>, + buildTypeSigning: Map, +) { + if (buildTypeSigning.isEmpty()) return + val types = buildTypes as NamedDomainObjectContainer + val signings = signingConfigs as NamedDomainObjectContainer + val pairs = resolveBuildTypeSigningPairs( + buildTypeSigning = buildTypeSigning, + buildTypeNames = types.names, + signingConfigNames = signings.names, + ) + pairs.forEach { (buildTypeName, signingName) -> + types.getByName(buildTypeName).signingConfig = signings.getByName(signingName) + } +} diff --git a/plugins/android/src/main/java/tools/forma/android/utils/android.kt b/plugins/android/src/main/java/tools/forma/android/utils/android.kt index 372251b3..1cc61bf9 100644 --- a/plugins/android/src/main/java/tools/forma/android/utils/android.kt +++ b/plugins/android/src/main/java/tools/forma/android/utils/android.kt @@ -8,6 +8,14 @@ import com.android.build.api.dsl.LibraryDefaultConfig import org.gradle.api.NamedDomainObjectContainer import tools.forma.config.AndroidProjectSettings +/** + * Shared build-type configuration for Android library and application targets. + * + * **Signing configs are not here** (F-097): APK signing is owned by [androidBinary] + * via dedicated `signingConfigs` / `buildTypeSigning` attrs so library DSLs cannot + * grow a parallel shopping API. Build-type lambdas may still set minify, proguard, + * buildConfigField, etc. + */ data class BuildConfiguration( val buildTypes: Map Unit> = emptyMap() ) diff --git a/plugins/android/src/test/kotlin/tools/forma/android/utils/FormaSigningConfigTest.kt b/plugins/android/src/test/kotlin/tools/forma/android/utils/FormaSigningConfigTest.kt new file mode 100644 index 00000000..e5dd7238 --- /dev/null +++ b/plugins/android/src/test/kotlin/tools/forma/android/utils/FormaSigningConfigTest.kt @@ -0,0 +1,116 @@ +package tools.forma.android.utils + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Pure unit coverage for F-097 signing model (no Gradle Project / AGP containers). + */ +class FormaSigningConfigTest { + + private val store = File("/tmp/forma-demo.keystore") + + @Test + fun `toAppliedFields copies required identity fields`() { + val config = FormaSigningConfig( + storeFile = store, + storePassword = "store-secret", + keyAlias = "upload", + keyPassword = "key-secret", + ) + val fields = config.toAppliedFields() + assertEquals(store, fields.storeFile) + assertEquals("store-secret", fields.storePassword) + assertEquals("upload", fields.keyAlias) + assertEquals("key-secret", fields.keyPassword) + assertNull(fields.storeType) + assertNull(fields.enableV1Signing) + assertNull(fields.enableV2Signing) + assertNull(fields.enableV3Signing) + assertNull(fields.enableV4Signing) + } + + @Test + fun `toAppliedFields preserves optional scheme flags and storeType`() { + val config = FormaSigningConfig( + storeFile = store, + storePassword = "p", + keyAlias = "a", + keyPassword = "k", + storeType = "pkcs12", + enableV1Signing = true, + enableV2Signing = true, + enableV3Signing = false, + enableV4Signing = true, + ) + val fields = config.toAppliedFields() + assertEquals("pkcs12", fields.storeType) + assertEquals(true, fields.enableV1Signing) + assertEquals(true, fields.enableV2Signing) + assertEquals(false, fields.enableV3Signing) + assertEquals(true, fields.enableV4Signing) + } + + @Test + fun `resolveBuildTypeSigningPairs returns empty for empty map`() { + assertTrue( + resolveBuildTypeSigningPairs( + buildTypeSigning = emptyMap(), + buildTypeNames = setOf("debug", "release"), + signingConfigNames = setOf("debug", "demoRelease"), + ).isEmpty() + ) + } + + @Test + fun `resolveBuildTypeSigningPairs keeps call-site order`() { + val pairs = resolveBuildTypeSigningPairs( + buildTypeSigning = linkedMapOf( + "release" to "demoRelease", + "debug" to "debug", + ), + buildTypeNames = setOf("debug", "release"), + signingConfigNames = setOf("debug", "demoRelease"), + ) + assertEquals( + listOf("release" to "demoRelease", "debug" to "debug"), + pairs, + ) + } + + @Test + fun `resolveBuildTypeSigningPairs rejects unknown build type`() { + val error = assertFailsWith { + resolveBuildTypeSigningPairs( + buildTypeSigning = mapOf("staging" to "demoRelease"), + buildTypeNames = setOf("debug", "release"), + signingConfigNames = setOf("demoRelease"), + ) + } + assertTrue(error.message!!.contains("unknown build type 'staging'")) + } + + @Test + fun `resolveBuildTypeSigningPairs rejects unknown signing config`() { + val error = assertFailsWith { + resolveBuildTypeSigningPairs( + buildTypeSigning = mapOf("release" to "prod"), + buildTypeNames = setOf("debug", "release"), + signingConfigNames = setOf("debug", "demoRelease"), + ) + } + assertTrue(error.message!!.contains("unknown signing config 'prod'")) + } + + @Test + fun `AndroidBinaryFeatureConfiguration defaults signing maps to empty`() { + // Smoke: binary feature config still constructs without signing (debug-only apps). + val empty = emptyMap() + assertTrue(empty.isEmpty()) + assertTrue(emptyMap().isEmpty()) + } +}