Harden Android generation and supply chain

ober

325d8b6ee77a3efd8092e43c69212708b0cc20a9

diff --git a/.build.yml b/.build.yml
index 4f76dc9..286f52b 100644
--- a/.build.yml
+++ b/.build.yml
@@ -1,12 +1,19 @@
-image: alpine/edge
+image: alpine/3.24
 packages:
-  - chez-scheme
-  - git
-  - make
+  - chez-scheme=10.3.0-r2
+  - git=2.54.0-r0
+  - make=4.4.1-r4
 sources:
-  - https://git.sr.ht/~lisp/jerboa
+  # Build dependency: full immutable commit, mirrored in dependencies.lock.json.
+  - "https://git.sr.ht/~lisp/jerboa#23ad83d28ac65cf6f0d2d702fd32934f207c26bf"
+  # The second source is the build subject selected by the SourceHut submitter.
   - https://git.sr.ht/~lisp/jerboa-android
 tasks:
   - smoke: |
       cd jerboa-android
-      JERBOA="chez --libdirs ../jerboa/lib --script" make test
+      test "$(apk info -v chez-scheme)" = chez-scheme-10.3.0-r2
+      test "$(apk info -v git)" = git-2.54.0-r0
+      test "$(apk info -v make)" = make-4.4.1-r4
+      test "$(git -C ../jerboa rev-parse HEAD)" = 23ad83d28ac65cf6f0d2d702fd32934f207c26bf
+      test "$(git -C ../jerboa rev-parse 'HEAD^{tree}')" = 6cbc7dd433a88801de82540e74433fc04d2474cd
+      JERBOA="chez --libdirs .:../jerboa/lib --script" make test
diff --git a/.gitignore b/.gitignore
index c625897..fa3cfd0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,7 @@
 build/
 .gradle/
+.tools/
+dist/
 local.properties
 *.apk
 *.aab
diff --git a/Makefile b/Makefile
index 87aa2a8..489b09c 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,9 @@
 JERBOA ?= jerboa
 BUILD_DIR ?= build/counter
+CLIENT_BUILD_DIR ?= build/client
 EXAMPLE ?= examples/counter/app.ss
+CLIENT_EXAMPLE ?= examples/client/app.ss
+SSD_EXAMPLE ?= tests/fixtures/ssd-app.ss
 JAVA_HOME ?= /opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
 ANDROID_HOME ?= $(HOME)/Library/Android/sdk
 ANDROID_SDK_ROOT ?= $(ANDROID_HOME)
@@ -9,13 +12,17 @@ export ANDROID_HOME
 export ANDROID_SDK_ROOT
 export PATH := $(JAVA_HOME)/bin:/opt/homebrew/bin:$(PATH)
 
-.PHONY: help clean generate test apk
+.PHONY: help clean generate test security supply-chain verify ssd-generate ssd-compile release-evidence apk
+.NOTPARALLEL:
 
 help:
 	@echo "Targets:"
 	@echo "  generate  Generate the counter Android project"
 	@echo "  apk       Generate and build the debug APK"
-	@echo "  test      Run the local generator smoke test"
+	@echo "  test      Run generator, adversarial, SSD, and supply-chain tests"
+	@echo "  verify    Run the release-required local verification suite"
+	@echo "  ssd-compile  Compile the SSD fixture with pinned Gradle/JDK tools"
+	@echo "  release-evidence  Emit SBOM/provenance from a clean committed tree"
 	@echo "  clean     Remove generated output"
 
 clean:
@@ -24,13 +31,50 @@ clean:
 generate:
 	$(JERBOA) jandroid.ss generate $(EXAMPLE) $(BUILD_DIR)
 
-apk: generate
-	gradle --no-daemon -p $(BUILD_DIR) assembleDebug
+apk: clean
+	$(MAKE) generate
+	scripts/verify-android-sdk.sh
+	JDK_HOME=$$(scripts/verified-jdk.sh --home); \
+	JAVA_HOME="$$JDK_HOME" scripts/verified-gradle.sh --no-daemon \
+		-p $(BUILD_DIR) assembleDebug
 
 test: clean generate
 	test -f $(BUILD_DIR)/settings.gradle.kts
 	test -f $(BUILD_DIR)/app/build.gradle.kts
+	cmp supply-chain/gradle-verification-metadata.xml $(BUILD_DIR)/gradle/verification-metadata.xml
 	test -f $(BUILD_DIR)/app/src/main/AndroidManifest.xml
 	test -f $(BUILD_DIR)/app/src/main/java/org/jerboa/counter/MainActivity.kt
 	grep -q 'class MainActivity' $(BUILD_DIR)/app/src/main/java/org/jerboa/counter/MainActivity.kt
 	grep -q 'count = (count + 1); render()' $(BUILD_DIR)/app/src/main/java/org/jerboa/counter/MainActivity.kt
+	$(JERBOA) jandroid.ss generate $(CLIENT_EXAMPLE) $(CLIENT_BUILD_DIR)
+	test -f $(CLIENT_BUILD_DIR)/app/src/main/java/org/jerboa/sampleclient/SampleClient.kt
+	grep -q 'generated named client' $(CLIENT_BUILD_DIR)/app/src/main/java/org/jerboa/sampleclient/SampleClient.kt
+	$(MAKE) security
+	$(MAKE) supply-chain
+
+security:
+	JERBOA="$(JERBOA)" tests/security-test.sh
+	JERBOA="$(JERBOA)" tests/ssd-security-test.sh
+
+supply-chain:
+	scripts/verify-supply-chain.sh
+
+verify: test
+
+ssd-generate: clean
+	$(JERBOA) jandroid.ss generate $(SSD_EXAMPLE) build/ssd-security
+
+ssd-compile: ssd-generate
+	scripts/verify-android-sdk.sh
+	JDK_HOME=$$(scripts/verified-jdk.sh --home); \
+	JANDROID_EXPECTED_RUNTIME_LOCK="$(CURDIR)/tests/fixtures/ssd-runtime.lock.json" \
+	JANDROID_EXPECTED_BUILD_LOCK="$(CURDIR)/tests/fixtures/ssd-build.lock.json" \
+	JANDROID_RESOLVED_SBOM="$(CURDIR)/build/ssd-security/resolved-android-runtime.spdx.json" \
+	JANDROID_RESOLVED_BUILD_SBOM="$(CURDIR)/build/ssd-security/resolved-gradle-build.spdx.json" \
+	JANDROID_SOURCE_COMMIT=$$(git rev-parse HEAD) \
+	JAVA_HOME="$$JDK_HOME" scripts/verified-gradle.sh --no-daemon \
+		-I "$(CURDIR)/scripts/resolved-artifacts.init.gradle" \
+		-p build/ssd-security compileDebugKotlin writeResolvedRuntimeSbom writeResolvedBuildSbom
+
+release-evidence:
+	scripts/release-evidence.sh
diff --git a/README.md b/README.md
index daa3a9a..9dd71c3 100644
--- a/README.md
+++ b/README.md
@@ -10,19 +10,23 @@ code.
 ## Quick Start
 
 ```sh
-make test
-make generate
+make verify
+make clean generate
 ```
 
+`make verify` runs the release-required adversarial and supply-chain checks.
+`make ssd-compile` performs the heavier generated-Android compile with the
+repository-pinned Gradle and JDK artifacts. See [SECURITY.md](SECURITY.md) for
+the trust boundaries and release policy.
+
 The generated counter project is written to `build/counter`.
 
-```sh
-cd build/counter
-gradle assembleDebug
-```
+Use `make apk` to regenerate it and build with the authenticated, pinned Gradle
+and JDK artifacts.
 
-The generated project uses Android Gradle Plugin 9.2.0 and the built-in Kotlin
-support introduced in AGP 9.x.
+The generated project defaults to Android Gradle Plugin 8.13.2 and Kotlin
+2.0.21, because those versions are already used by the larger game app. Specs
+can override both versions.
 
 ## App Spec Shape
 
@@ -35,6 +39,8 @@ Specs are valid Jerboa files that define a quoted `app` value:
   '(android-app
      (id "org.jerboa.counter")
      (name "Jerboa Counter")
+     (version-code 1)
+     (version-name "0.1.0")
      (screen Main
        (state count 0)
        (column
@@ -49,6 +55,7 @@ Current supported view forms:
 - `(text part ...)`
 - `(button label action ...)`
 - `(spacer height)`
+- `(small-text part ...)`
 
 Current supported action form:
 
@@ -59,6 +66,80 @@ Current supported expression forms:
 - strings, numbers, booleans, symbols
 - binary `+`, `-`, `*`, `/`
 
+Android project metadata supported by the generator:
+
+- `(id "com.example.app")`
+- `(name "Display Name")`
+- `(root-name "GradleRootName")`
+- `(compile-sdk 35)`, `(build-tools-version "36.0.0")`, `(min-sdk 26)`, `(target-sdk 35)`
+- `(version-code 10)`, `(version-name "0.1.9")`
+- `(android-gradle-plugin "8.13.2")`, `(kotlin-version "2.0.21")`
+- `(jvm-toolchain 17)`
+- `(gradle-property "android.useAndroidX" "true")`
+- `(permission "android.permission.INTERNET")`
+- `(permission "android.permission.READ_EXTERNAL_STORAGE" (max-sdk 32))`
+- `(allow-backup #t)`, `(uses-cleartext-traffic #t)`
+- `(theme "@android:style/Theme.Material.NoActionBar")`
+- `(ndk-version "28.2.13676358")`, `(abi-filter "arm64-v8a")`
+- `(asset-dir "relative/path")`
+- `(jni-lib-dir "relative/path")`
+- `(dependency "androidx.documentfile:documentfile:1.1.0")`
+- `(kotlin-source-dir "relative/path")`
+- `(fragment "relative/path.ss")`
+- `(client original-tactics)`
+
+`asset-dir` and `jni-lib-dir` copy directory contents into the generated
+Android project. This lets app repos keep only Jerboa specs and binary assets
+under source control while Kotlin remains generated output.
+
+`kotlin-source-dir` is a migration bridge for existing apps. It copies existing
+Kotlin source into the generated project after the default generated
+`MainActivity.kt`, so a large app can move to Jerboa-owned Android project
+generation before each view is rewritten as higher-level Jerboa forms.
+
+`fragment` reads another Jerboa file that defines:
+
+```scheme
+(def fragment
+  '((kotlin-file "com/example/Extra.kt" "package com.example\n...")))
+```
+
+Large fragments can use line lists instead of one string:
+
+```scheme
+(def fragment
+  '((kotlin-file-lines "com/example/Extra.kt"
+      ("package com.example"
+       ""
+       "class Extra"))))
+```
+
+Fragments are expanded into the app spec before generation. They are useful for
+large generated source sets that should stay in `.ss` files instead of the main
+app spec.
+
+`client` expands a named generator-owned template from `templates/<name>.ss`.
+This is the preferred bridge for larger apps while the higher-level Android DSL
+is growing, because application repositories can keep concise Jerboa app specs
+instead of carrying large generated-source fragments.
+
+All source paths are relative to the app specification and may not contain
+symbolic links. Output paths are descriptor-relative, no-follow, exclusive
+creates; generation intentionally fails if a target already exists. Dependency
+coordinates must use an exact `group:name:version` (no `+`, ranges, `latest`, or
+snapshot selectors). Generated projects include strict Gradle verification
+metadata for the reviewed graph; adding a dependency also requires a reviewed
+checksum update (or an application-owned verification manifest) before Gradle
+will execute the build. The generated settings also constrain known-vulnerable
+transitive build dependencies to the reviewed versions recorded in
+`dependencies.lock.json`.
+
+The `ssd-review` client keeps remote synchronization disabled by default. A
+deployment must locally provision an HTTPS origin, SPKI SHA-256 pin, and bearer
+token; remote identifiers are hashed into local filenames and all HTTP, ZIP,
+entry-count, expansion-ratio, and storage budgets are enforced. The precise
+configuration and limits are documented in [SECURITY.md](SECURITY.md).
+
 ## Direction
 
 The next useful step is a typed backend beside Jerboa's existing Rust and LLVM
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..6df9200
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,99 @@
+# Security model
+
+`jerboa-android` treats an app specification and every referenced source tree
+as untrusted build input. Generation is confined to one output-directory
+capability; generated Android applications have a separate runtime boundary.
+
+## Generator output boundary
+
+The generator opens the physical output root once and resolves every directory
+and file relative to that descriptor. It rejects absolute output names, empty,
+`.` and `..` components, alternate separators, NULs, unexpected top-level
+directories/extensions, invalid Java package components, and symbolic-link
+parents. Files are created exclusively with no-follow semantics and mode 0600;
+an existing output is never replaced. Use a new or empty output directory for
+each generation.
+
+Referenced fragments and copied source directories must be relative to the app
+specification. Symbolic links are rejected. Copying is limited to 10,000 files,
+32 MiB per file, 512 MiB total, and depth 32. Assets accept regular files, JNI
+trees accept only `.so`, and Kotlin trees accept only `.kt`.
+
+These controls protect against an untrusted repository changing an inode
+outside the requested output tree. They do not make generated Kotlin or Gradle
+code safe to execute: a specification can intentionally emit application code,
+so review it before building or installing the result.
+
+## SSD review client
+
+Remote synchronization is disabled unless trusted local provisioning writes all
+three values to the app-private `ssd_review_remote` preferences:
+
+- `base_url`: an HTTPS origin without user information, query, or fragment
+- `spki_sha256`: `sha256/` plus the base64 SHA-256 of the expected leaf SPKI
+- `bearer_token`: a locally issued bearer token of 32–4096 characters
+
+The client requires normal platform certificate validation and the configured
+SPKI pin during the TLS handshake, sends credentials only to the configured
+same origin, and refuses redirects. Rotate a pin through a trusted application
+update or device-management channel before the old certificate is removed.
+Production deployments should provision bearer material through a
+Keystore-backed mechanism; app-private preferences do not protect a compromised
+or rooted device.
+
+Remote `source_key` values are narrow identifiers, never paths. Local filenames
+are SHA-256-derived opaque leaves under app-private directories, and writes use
+contained no-follow parents plus an fsynced atomic move. User-selected sync
+locations come only from Android's Storage Access Framework.
+
+Network/import limits are 2 MiB each for compressed and expanded text, 32 MiB compressed ZIP, 4 MiB per
+expanded entry, 64 MiB total expansion, 512 entries, depth 8, 240 path
+characters, and a 100:1 compression ratio after a 64 KiB floor. App-private
+storage is capped at 128 MiB and 4096 files. Imports stage on disk, enforce all
+budgets before installation, and delete partial staging on failure.
+
+## Build and release assurance
+
+[`dependencies.lock.json`](dependencies.lock.json) is the machine-readable
+input manifest. The Jerboa source dependency is pinned by commit and tree in
+SourceHut; Android plugin, Kotlin, Gradle distribution digest, Temurin JDK
+artifacts, SDK/API revisions, build tools, and SSD AndroidX dependency are
+declared there. The SourceHut smoke image release and installed package builds
+are also fixed. Dynamic Maven selectors and snapshot versions are rejected.
+The SSD fixture's complete resolved runtime and executable Gradle plugin graphs
+and artifact digests are checked against `tests/fixtures/ssd-runtime.lock.json`
+and `tests/fixtures/ssd-build.lock.json` during compilation. Every generated
+project also receives strict Gradle verification metadata, so plugin, compiler,
+metadata, and runtime artifacts are checked before use. Reviewed resolution
+constraints keep vulnerable transitive plugin requests off the effective build
+classpath; both the constraints and resolved artifact hashes are locked.
+
+`make verify` is the required local gate. `make ssd-compile` additionally
+downloads Gradle and JDK only over HTTPS, authenticates them with repository-held
+SHA-256 values, re-extracts them, and compiles the generated SSD Kotlin source.
+`make release-evidence` refuses a dirty worktree, runs through the pinned Jerboa
+source with Chez 10.3.0, compiles the SSD fixture, and emits source/tree
+identity, tool versions, checksums, a declared-tool SPDX document, and SHA-256
+inventories of every resolved Android runtime and executable build-classpath
+artifact.
+The release gate also runs the pinned OSV-Scanner 2.4.0 binary against both the
+resolved runtime and resolved build-classpath SPDX documents, fails on a
+reported vulnerability, and retains the JSON results and scan time as evidence.
+The verification manifest is intentionally a superset because Gradle records
+metadata candidates considered during conflict resolution; advisory decisions
+therefore use the locked effective classpaths, not evicted candidates.
+
+SUPPLY-03 is not applicable because CI does not download a Jerboa executable;
+it executes the exact pinned Jerboa source checkout. SUPPLY-04 is not currently
+applicable because this repository has no GitHub Actions workflow, but the gate
+rejects future non-SHA action references. The repository-under-test SourceHut
+source is the build subject selected by the submitter, not a dependency; its
+commit and tree are recorded in release evidence.
+
+Generated applications can add their own Maven, native, asset, and model inputs.
+Those are outside this repository's fixed graph and must have their own Gradle
+dependency verification metadata/locks, resolved-artifact SBOM, advisory scan,
+and provenance before an APK is released.
+
+Report suspected vulnerabilities privately to the maintainers and avoid
+including credentials or sensitive documents in a report.
diff --git a/dependencies.lock.json b/dependencies.lock.json
new file mode 100644
index 0000000..e055aeb
--- /dev/null
+++ b/dependencies.lock.json
@@ -0,0 +1,74 @@
+{
+  "schema": 1,
+  "sourcehut_smoke_environment": {
+    "image": "alpine/3.24",
+    "packages": {
+      "chez-scheme": "10.3.0-r2",
+      "git": "2.54.0-r0",
+      "make": "4.4.1-r4"
+    }
+  },
+  "generator_runtime": {
+    "name": "jerboa",
+    "repository": "https://git.sr.ht/~lisp/jerboa",
+    "commit": "23ad83d28ac65cf6f0d2d702fd32934f207c26bf",
+    "tree": "6cbc7dd433a88801de82540e74433fc04d2474cd"
+  },
+  "assurance_tools": {
+    "osv_scanner": {
+      "version": "2.4.0",
+      "macos_aarch64_url": "https://github.com/google/osv-scanner/releases/download/v2.4.0/osv-scanner_darwin_arm64",
+      "macos_aarch64_sha256": "9ca3185ad63e9ab54f7cb90f46a7362be02d80e37f0123d095a54355ea202f5d",
+      "linux_x86_64_url": "https://github.com/google/osv-scanner/releases/download/v2.4.0/osv-scanner_linux_amd64",
+      "linux_x86_64_sha256": "15314940c10d26af9c6649f150b8a47c1262e8fc7e17b1d1029b0e479e8ed8a0"
+    }
+  },
+  "generated_android_defaults": {
+    "android_gradle_plugin": "8.13.2",
+    "kotlin_gradle_plugin": "2.0.21",
+    "build_classpath_security_overrides": {
+      "io.netty": "4.1.135.Final",
+      "org.bouncycastle": "1.84",
+      "com.google.protobuf": "3.25.5",
+      "org.apache.commons:commons-compress": "1.28.0",
+      "org.bitbucket.b_c:jose4j": "0.9.6",
+      "org.jdom:jdom2": "2.0.6.1"
+    },
+    "gradle": {
+      "version": "8.13",
+      "distribution": "https://services.gradle.org/distributions/gradle-8.13-bin.zip",
+      "sha256": "20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78"
+    },
+    "jdk": {
+      "version": "17.0.19+10",
+      "macos_aarch64_url": "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.19%2B10/OpenJDK17U-jdk_aarch64_mac_hotspot_17.0.19_10.tar.gz",
+      "macos_aarch64_sha256": "8fa1eff40bb637a33613b2ccb8b12c70dc3661cc22cf8e784943715769a05336",
+      "linux_x86_64_url": "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.19%2B10/OpenJDK17U-jdk_x64_linux_hotspot_17.0.19_10.tar.gz",
+      "linux_x86_64_sha256": "d8afc263758141a66e0e3aafc321e783f7016696f4eaea067d340a269037d331"
+    },
+    "jdk_language_version": 17,
+    "android_sdk_platforms": {"35": 2, "36": 2},
+    "android_build_tools": "36.0.0",
+    "aapt2": {
+      "version": "8.13.2-14304508",
+      "macos_sha256": "0d47f17c3924e5472b6125aa608d949dd7f46510889729671f31f2f4d801e8e7",
+      "linux_x86_64_sha256": "839609d6d776d6dd60a02aa577d97193ce3e650cf1deaabf062321e23bbd6bf6"
+    },
+    "gradle_verification_metadata_sha256": "a3e045c36af61ccc34e805761b1a1a42a00b6f88c5a98abd0d058e4f1ef3a97b",
+    "android_compile_sdk": 35,
+    "android_target_sdk": 35,
+    "android_min_sdk": 26
+  },
+  "ssd_review_fixture": {
+    "androidx_documentfile": "androidx.documentfile:documentfile:1.1.0",
+    "resolved_runtime_lock": {
+      "path": "tests/fixtures/ssd-runtime.lock.json",
+      "sha256": "beb65a1320dafa71ba8c74ac0f06cbbe55bd62250d90ab02f53092be3e0ffa6f"
+    },
+    "resolved_build_classpath_lock": {
+      "path": "tests/fixtures/ssd-build.lock.json",
+      "sha256": "a41508b7d2b016f3c8aef0ac7877922b147aaa532b0be0a1cf5824fe01522f99"
+    }
+  },
+  "github_actions": []
+}
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 78bf088..65c36bc 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -12,6 +12,12 @@
 - Add text input, lists, checkboxes, and simple resources.
 - Add generated tests for the emitted Kotlin files.
 - Add an Android SDK CI job once the SourceHut image setup is settled.
+- Support generated game clients that need assets, JNI libraries, permissions,
+  and Android SDK/NDK metadata.
+- Add higher-level generator forms for custom Canvas views so apps do not need
+  hand-written Kotlin source files for tactical maps or board views.
+- Retire `kotlin-source-dir` once the custom view generator can describe the
+  SFC-style tactical panels directly.
 
 ## Compiler Track
 
diff --git a/examples/client/app.ss b/examples/client/app.ss
new file mode 100644
index 0000000..9bc4f96
--- /dev/null
+++ b/examples/client/app.ss
@@ -0,0 +1,14 @@
+(import (jerboa prelude))
+
+(def app
+  '(android-app
+     (id "org.jerboa.sampleclient")
+     (name "Jerboa Client Example")
+     (version-code 1)
+     (version-name "0.1.0")
+     (client sample-client)
+     (screen Main
+       (state status "ready")
+       (column
+         (text "Named Client")
+         (small-text "Status: " status)))))
diff --git a/jandroid.ss b/jandroid.ss
index 8e8badc..aea9013 100644
--- a/jandroid.ss
+++ b/jandroid.ss
@@ -1,5 +1,12 @@
 (import (jerboa prelude)
-        (only (chezscheme) mkdir file-directory?))
+        (only (chezscheme) file-directory?)
+        (jandroid secure-output))
+
+(def *max-copy-files* 10000)
+(def *max-copy-file-bytes* (* 32 1024 1024))
+(def *max-copy-total-bytes* (* 512 1024 1024))
+(def *max-copy-depth* 32)
+(defstruct copy-budget (files bytes))
 
 (def (usage)
   (displayln "Usage:")
@@ -31,23 +38,71 @@
             #f))
       (error 'find-app "expected `(def app '(...))` in app spec")))
 
-(def (ensure-directory path)
-  (unless (or (string=? path "")
-              (string=? path ".")
-              (file-directory? path))
-    (let ((parent (path-directory path)))
-      (when (and (not (string=? parent ""))
-                 (not (string=? parent "."))
-                 (not (string=? parent path)))
-        (ensure-directory parent)))
-    (try (mkdir path)
-         (catch (exn) (void)))))
+(def (find-fragment forms path)
+  (or (for/or ((form forms))
+        (if (and (pair? form)
+                 (eq? (car form) 'def)
+                 (pair? (cdr form))
+                 (eq? (cadr form) 'fragment)
+                 (pair? (cddr form)))
+            (unquote-form (caddr form))
+            #f))
+      (error 'find-fragment "expected `(def fragment '(...))` in fragment file" path)))
 
 (def (entries spec)
   (if (and (pair? spec) (eq? (car spec) 'android-app))
       (cdr spec)
       (error 'entries "expected android-app spec" spec)))
 
+(def (fragment-entry? item)
+  (and (pair? item)
+       (eq? (car item) 'fragment)
+       (= (length item) 2)
+       (string? (cadr item))))
+
+(def (client-entry? item)
+  (and (pair? item)
+       (eq? (car item) 'client)
+       (pair? (cdr item))
+       (or (symbol? (cadr item)) (string? (cadr item)))))
+
+(def (client-name item)
+  (let ((name (cadr item)))
+    (let ((text (if (symbol? name) (symbol->string name) name)))
+      (unless (and (> (string-length text) 0)
+                   (<= (string-length text) 64)
+                   (every (lambda (ch)
+                            (or (char-alphabetic? ch)
+                                (char-numeric? ch)
+                                (char=? ch #\-)
+                                (char=? ch #\_)))
+                          (string->list text)))
+        (error 'client "unsafe named client identifier" text))
+      text)))
+
+(def (client-template-path generator-dir item)
+  (path-join generator-dir
+             "templates"
+             (string-append (client-name item) ".ss")))
+
+(def (expand-fragments spec base-dir generator-dir)
+  (let ((items (entries spec)))
+    (cons 'android-app
+          (apply append
+                 (map (lambda (item)
+                        (cond
+                          ((fragment-entry? item)
+                           (let* ((path (resolve-local-source-path
+                                         base-dir (cadr item) '(".ss")))
+                                  (fragment (find-fragment (read-all-forms path) path)))
+                             fragment))
+                          ((client-entry? item)
+                           (let* ((path (client-template-path generator-dir item))
+                                  (fragment (find-fragment (read-all-forms path) path)))
+                             fragment))
+                          (else (list item))))
+                      items)))))
+
 (def (entry-value items key default)
   (let loop ((xs items))
     (cond
@@ -58,6 +113,26 @@
        (cadar xs))
       (else (loop (cdr xs))))))
 
+(def (entry-values items key)
+  (reverse
+    (let loop ((xs items) (out '()))
+      (cond
+        ((null? xs) out)
+        ((and (pair? (car xs))
+              (eq? (caar xs) key)
+              (pair? (cdar xs)))
+         (loop (cdr xs) (cons (cadar xs) out)))
+        (else (loop (cdr xs) out))))))
+
+(def (entry-forms items key)
+  (reverse
+    (let loop ((xs items) (out '()))
+      (cond
+        ((null? xs) out)
+        ((and (pair? (car xs)) (eq? (caar xs) key))
+         (loop (cdr xs) (cons (car xs) out)))
+        (else (loop (cdr xs) out))))))
+
 (def (find-entry items key)
   (let loop ((xs items))
     (cond
@@ -65,20 +140,85 @@
       ((and (pair? (car xs)) (eq? (caar xs) key)) (car xs))
       (else (loop (cdr xs))))))
 
-(def (app-id spec)
-  (entry-value (entries spec) 'id "org.jerboa.generated"))
-
-(def (app-name spec)
-  (entry-value (entries spec) 'name "Jerboa Android"))
-
+(def (immutable-version value who)
+  (unless (and (string? value)
+               (> (string-length value) 0)
+               (<= (string-length value) 96)
+               (every (lambda (ch)
+                        (or (char-alphabetic? ch)
+                            (char-numeric? ch)
+                            (memv ch '(#\. #\_ #\-))))
+                      (string->list value))
+               (not (string-prefix? "latest." (string-downcase value)))
+               (not (string-suffix? "-snapshot" (string-downcase value))))
+    (error who "dependency/tool version must be an immutable exact version" value))
+  value)
+
+(def (immutable-dependency-coordinate value)
+  (unless (string? value)
+    (error 'dependency "dependency coordinate must be a string" value))
+  (let ([parts (string-split value #\:)])
+    (unless (and (= (length parts) 3)
+                 (every (lambda (part) (> (string-length part) 0)) parts))
+      (error 'dependency "expected exact group:name:version coordinate" value))
+    (immutable-version (caddr parts) 'dependency)
+    value))
+
+(def (bounded-integer value who minimum maximum)
+  (unless (and (integer? value) (<= minimum value maximum))
+    (error who "integer is outside the supported range" value minimum maximum))
+  value)
+
+(def (app-id spec) (entry-value (entries spec) 'id "org.jerboa.generated"))
+(def (app-name spec) (entry-value (entries spec) 'name "Jerboa Android"))
+(def (root-project-name spec) (entry-value (entries spec) 'root-name (app-name spec)))
 (def (compile-sdk spec)
-  (entry-value (entries spec) 'compile-sdk 36))
-
+  (bounded-integer (entry-value (entries spec) 'compile-sdk 35) 'compile-sdk 1 100))
+(def (build-tools-version spec)
+  (immutable-version
+    (entry-value (entries spec) 'build-tools-version "36.0.0")
+    'build-tools-version))
 (def (target-sdk spec)
-  (entry-value (entries spec) 'target-sdk (compile-sdk spec)))
-
+  (bounded-integer
+    (entry-value (entries spec) 'target-sdk (compile-sdk spec))
+    'target-sdk 1 100))
 (def (min-sdk spec)
-  (entry-value (entries spec) 'min-sdk 26))
+  (bounded-integer (entry-value (entries spec) 'min-sdk 26) 'min-sdk 1 100))
+(def (version-code spec)
+  (bounded-integer (entry-value (entries spec) 'version-code 1) 'version-code 1 2147483647))
+(def (version-name spec) (entry-value (entries spec) 'version-name "0.1.0"))
+(def (agp-version spec)
+  (immutable-version
+    (entry-value (entries spec) 'android-gradle-plugin "8.13.2")
+    'android-gradle-plugin))
+(def (kotlin-version spec)
+  (immutable-version
+    (entry-value (entries spec) 'kotlin-version "2.0.21")
+    'kotlin-version))
+(def (kotlin? spec) (entry-value (entries spec) 'kotlin #t))
+(def (jvm-toolchain spec)
+  (bounded-integer (entry-value (entries spec) 'jvm-toolchain 17) 'jvm-toolchain 8 30))
+(def (ndk-version spec)
+  (let ([version (entry-value (entries spec) 'ndk-version #f)])
+    (if version (immutable-version version 'ndk-version) #f)))
+(def (allow-backup spec) (entry-value (entries spec) 'allow-backup #f))
+(def (uses-cleartext-traffic spec) (entry-value (entries spec) 'uses-cleartext-traffic #f))
+(def (theme spec) (entry-value (entries spec) 'theme "@style/AppTheme"))
+(def (permissions spec) (entry-values (entries spec) 'permission))
+(def (permission-forms spec) (entry-forms (entries spec) 'permission))
+(def (dependencies spec) (entry-values (entries spec) 'dependency))
+(def (abi-filters spec) (entry-values (entries spec) 'abi-filter))
+(def (asset-dirs spec) (entry-values (entries spec) 'asset-dir))
+(def (jni-lib-dirs spec) (entry-values (entries spec) 'jni-lib-dir))
+(def (kotlin-source-dirs spec) (entry-values (entries spec) 'kotlin-source-dir))
+(def (raw-files spec) (entry-forms (entries spec) 'file))
+(def (gradle-property-forms spec) (entry-forms (entries spec) 'gradle-property))
+(def (kotlin-file-entry? item)
+  (and (pair? item)
+       (or (eq? (car item) 'kotlin-file)
+           (eq? (car item) 'kotlin-file-lines))))
+
+(def (kotlin-files spec) (filter kotlin-file-entry? (entries spec)))
 
 (def (main-screen spec)
   (or (find-entry (entries spec) 'screen)
@@ -89,41 +229,50 @@
       (cadr screen)
       (error 'screen-name "screen must have a symbol name" screen)))
 
-(def (screen-items screen)
-  (cddr screen))
+(def (screen-items screen) (cddr screen))
 
-(def (state-entry? item)
-  (and (pair? item) (eq? (car item) 'state)))
+(def (state-entry? item) (and (pair? item) (eq? (car item) 'state)))
 
-(def (screen-states screen)
-  (filter state-entry? (screen-items screen)))
+(def (screen-states screen) (filter state-entry? (screen-items screen)))
 
 (def (screen-layout screen)
   (or (for/or ((item (screen-items screen)))
         (if (state-entry? item) #f item))
       '(column)))
 
-(def (kotlin-ident sym)
-  (symbol->string sym))
+(def (kotlin-ident sym) (symbol->string sym))
 
-(def (kotlin-string s)
+(def (escape-string s quote-ch)
   (with-output-to-string
     (lambda ()
-      (display "\"")
       (for ((ch (in-string s)))
         (cond
-          ((char=? ch #\") (display "\\\""))
+          ((char=? ch quote-ch) (display "\\") (display ch))
           ((char=? ch #\\) (display "\\\\"))
           ((char=? ch #\newline) (display "\\n"))
           ((char=? ch #\tab) (display "\\t"))
-          (else (display ch))))
-      (display "\""))))
+          (else (display ch)))))))
+
+(def (kotlin-string s) (string-append "\"" (escape-string s #\") "\""))
+
+(def (xml-escape s)
+  (with-output-to-string
+    (lambda ()
+      (for ((ch (in-string s)))
+        (cond
+          ((char=? ch #\") (display "&quot;"))
+          ((char=? ch #\&) (display "&amp;"))
+          ((char=? ch #\<) (display "&lt;"))
+          ((char=? ch #\>) (display "&gt;"))
+          (else (display ch)))))))
+
+(def (bool-string value) (if value "true" "false"))
 
 (def (kotlin-value value)
   (cond
     ((string? value) (kotlin-string value))
     ((symbol? value) (kotlin-ident value))
-    ((boolean? value) (if value "true" "false"))
+    ((boolean? value) (bool-string value))
     ((number? value) (format "~a" value))
     (else (error 'kotlin-value "unsupported literal" value))))
 
@@ -161,14 +310,9 @@
              parts)
         " + ")))
 
-(def (indent n)
-  (make-string n #\space))
-
-(def (line n text)
-  (string-append (indent n) text "\n"))
-
-(def (add-view-prefix target)
-  (if (string-empty? target) "addView" (string-append target ".addView")))
+(def (indent n) (make-string n #\space))
+(def (line n text) (string-append (indent n) text "\n"))
+(def (add-view-prefix target) (if (string-empty? target) "addView" (string-append target ".addView")))
 
 (def (emit-view item level target context)
   (cond
@@ -179,8 +323,7 @@
        (line (+ level 4) "gravity = Gravity.CENTER")
        (line (+ level 4) "setPadding(dp(24), dp(24), dp(24), dp(24))")
        (apply string-append
-              (map (lambda (child)
-                     (emit-view child (+ level 4) "" "this@MainActivity"))
+              (map (lambda (child) (emit-view child (+ level 4) "" "this@MainActivity"))
                    (cdr item)))
        (line level "})")))
     ((and (pair? item) (eq? (car item) 'row))
@@ -189,8 +332,7 @@
        (line (+ level 4) "orientation = LinearLayout.HORIZONTAL")
        (line (+ level 4) "gravity = Gravity.CENTER")
        (apply string-append
-              (map (lambda (child)
-                     (emit-view child (+ level 4) "" "this@MainActivity"))
+              (map (lambda (child) (emit-view child (+ level 4) "" "this@MainActivity"))
                    (cdr item)))
        (line level "})")))
     ((and (pair? item) (eq? (car item) 'text))
@@ -200,9 +342,15 @@
        (line (+ level 4) "textSize = 28f")
        (line (+ level 4) "gravity = Gravity.CENTER")
        (line level "})")))
+    ((and (pair? item) (eq? (car item) 'small-text))
+     (string-append
+       (line level (string-append (add-view-prefix target) "(TextView(" context ").apply {"))
+       (line (+ level 4) (string-append "text = " (text-expr (cdr item))))
+       (line (+ level 4) "textSize = 16f")
+       (line (+ level 4) "gravity = Gravity.CENTER")
+       (line level "})")))
     ((and (pair? item) (eq? (car item) 'button) (>= (length item) 2))
-     (let ((label (cadr item))
-           (actions (cddr item)))
+     (let ((label (cadr item)) (actions (cddr item)))
        (string-append
          (line level (string-append (add-view-prefix target) "(Button(" context ").apply {"))
          (line (+ level 4) (string-append "text = " (kotlin-expr label)))
@@ -217,8 +365,8 @@
      (string-append
        (line level (string-append (add-view-prefix target) "(Space(" context ").apply {"))
        (line (+ level 4) (string-append "layoutParams = LinearLayout.LayoutParams(1, dp("
-                                  (kotlin-expr (cadr item))
-                                  "))"))
+                                        (kotlin-expr (cadr item))
+                                        "))"))
        (line level "})")))
     (else (error 'emit-view "unsupported view item" item))))
 
@@ -227,8 +375,7 @@
       (cadr state)
       (error 'state-name "state entries are `(state name initial-value)`" state)))
 
-(def (state-initial state)
-  (caddr state))
+(def (state-initial state) (caddr state))
 
 (def (emit-state state)
   (string-append "    private var "
@@ -237,13 +384,31 @@
                  (kotlin-expr (state-initial state))
                  "\n"))
 
+(def (java-identifier-component? value)
+  (and (string? value)
+       (> (string-length value) 0)
+       (<= (string-length value) 128)
+       (let ([first (string-ref value 0)])
+         (or (char-alphabetic? first) (char=? first #\_)))
+       (every (lambda (ch)
+                (or (char-alphabetic? ch)
+                    (char-numeric? ch)
+                    (char=? ch #\_)))
+              (string->list value))))
+
 (def (package-path package-name)
-  (let loop ((parts (string-split package-name #\.))
-             (path ""))
+  (unless (and (string? package-name)
+               (<= (string-length package-name) 255))
+    (error 'package-path "invalid package name" package-name))
+  (let ([parts (string-split package-name #\.)])
+    (unless (and (pair? parts) (every java-identifier-component? parts))
+      (error 'package-path "package components must be Java identifiers"
+             package-name))
+    (let loop ((parts parts) (path ""))
     (cond
       ((null? parts) path)
       ((string-empty? path) (loop (cdr parts) (car parts)))
-      (else (loop (cdr parts) (path-join path (car parts)))))))
+      (else (loop (cdr parts) (path-join path (car parts))))))))
 
 (def (main-activity-kt spec)
   (let* ((package-name (app-id spec))
@@ -288,6 +453,24 @@
     "        gradlePluginPortal()\n"
     "    }\n"
     "}\n\n"
+    "// Keep the build's executable plugin classpath on reviewed, advisory-clean\n"
+    "// versions even when a plugin requests an older transitive dependency.\n"
+    "gradle.beforeProject {\n"
+    "    buildscript.configurations.configureEach {\n"
+    "        resolutionStrategy.eachDependency {\n"
+    "            when (requested.group) {\n"
+    "                \"io.netty\" -> useVersion(\"4.1.135.Final\")\n"
+    "                \"org.bouncycastle\" -> useVersion(\"1.84\")\n"
+    "                \"com.google.protobuf\" -> useVersion(\"3.25.5\")\n"
+    "            }\n"
+    "            when (\"${requested.group}:${requested.name}\") {\n"
+    "                \"org.apache.commons:commons-compress\" -> useVersion(\"1.28.0\")\n"
+    "                \"org.bitbucket.b_c:jose4j\" -> useVersion(\"0.9.6\")\n"
+    "                \"org.jdom:jdom2\" -> useVersion(\"2.0.6.1\")\n"
+    "            }\n"
+    "        }\n"
+    "    }\n"
+    "}\n\n"
     "dependencyResolutionManagement {\n"
     "    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)\n"
     "    repositories {\n"
@@ -295,40 +478,138 @@
     "        mavenCentral()\n"
     "    }\n"
     "}\n\n"
-    "rootProject.name = " (kotlin-string (app-name spec)) "\n"
+    "rootProject.name = " (kotlin-string (root-project-name spec)) "\n"
     "include(\":app\")\n"))
 
-(def (root-build-gradle)
+(def (root-build-gradle spec)
   (string-append
     "plugins {\n"
-    "    id(\"com.android.application\") version \"9.2.0\" apply false\n"
+    "    id(\"com.android.application\") version " (kotlin-string (agp-version spec)) " apply false\n"
+    (if (kotlin? spec)
+        (string-append "    id(\"org.jetbrains.kotlin.android\") version "
+                       (kotlin-string (kotlin-version spec))
+                       " apply false\n")
+        "")
     "}\n"))
 
+(def (emit-abi-filters filters)
+  (if (null? filters)
+      ""
+      (string-append
+        "        ndk {\n"
+        (apply string-append
+               (map (lambda (abi)
+                      (string-append "            abiFilters += " (kotlin-string abi) "\n"))
+                    filters))
+        "        }\n")))
+
+(def (dependency-lines spec)
+  (let ((deps (dependencies spec)))
+    (if (null? deps)
+        ""
+        (string-append
+          "\n"
+          "dependencies {\n"
+          (apply string-append
+                 (map (lambda (dep)
+                        (string-append
+                          "    implementation("
+                          (kotlin-string (immutable-dependency-coordinate dep))
+                          ")\n"))
+                      deps))
+          "}\n"))))
+
+(def (gradle-property-line property-form)
+  (unless (and (= (length property-form) 3)
+               (string? (cadr property-form))
+               (string? (caddr property-form)))
+    (error 'gradle-property "expected `(gradle-property key value)`" property-form))
+  (string-append (cadr property-form) "=" (caddr property-form) "\n"))
+
+(def (gradle-properties-file spec)
+  (apply string-append (map gradle-property-line (gradle-property-forms spec))))
+
 (def (app-build-gradle spec)
-  (string-append
-    "plugins {\n"
-    "    id(\"com.android.application\")\n"
-    "}\n\n"
-    "android {\n"
-    "    namespace = " (kotlin-string (app-id spec)) "\n"
-    "    compileSdk = " (format "~a" (compile-sdk spec)) "\n\n"
-    "    defaultConfig {\n"
-    "        applicationId = " (kotlin-string (app-id spec)) "\n"
-    "        minSdk = " (format "~a" (min-sdk spec)) "\n"
-    "        targetSdk = " (format "~a" (target-sdk spec)) "\n"
-    "        versionCode = 1\n"
-    "        versionName = \"0.1.0\"\n"
-    "    }\n"
-    "}\n"))
+  (let ((ndk (ndk-version spec))
+        (abis (abi-filters spec)))
+    (string-append
+      "plugins {\n"
+      "    id(\"com.android.application\")\n"
+      (if (kotlin? spec) "    id(\"org.jetbrains.kotlin.android\")\n" "")
+      "}\n\n"
+      "android {\n"
+      "    namespace = " (kotlin-string (app-id spec)) "\n"
+      "    compileSdk = " (format "~a" (compile-sdk spec)) "\n"
+      "    buildToolsVersion = " (kotlin-string (build-tools-version spec)) "\n"
+      (if ndk (string-append "    ndkVersion = " (kotlin-string ndk) "\n") "")
+      "\n"
+      "    defaultConfig {\n"
+      "        applicationId = " (kotlin-string (app-id spec)) "\n"
+      "        minSdk = " (format "~a" (min-sdk spec)) "\n"
+      "        targetSdk = " (format "~a" (target-sdk spec)) "\n"
+      "        versionCode = " (format "~a" (version-code spec)) "\n"
+      "        versionName = " (kotlin-string (version-name spec)) "\n"
+      (emit-abi-filters abis)
+      "    }\n"
+      "\n"
+      "    compileOptions {\n"
+      "        sourceCompatibility = JavaVersion.VERSION_" (format "~a" (jvm-toolchain spec)) "\n"
+      "        targetCompatibility = JavaVersion.VERSION_" (format "~a" (jvm-toolchain spec)) "\n"
+      "    }\n"
+      "}\n"
+      (if (kotlin? spec)
+          (string-append "\n"
+                         "kotlin {\n"
+                         "    jvmToolchain(" (format "~a" (jvm-toolchain spec)) ")\n"
+                         "}\n")
+          "")
+      (dependency-lines spec))))
+
+(def (permission-attr-value attrs key default)
+  (let loop ((xs attrs))
+    (cond
+      ((null? xs) default)
+      ((and (pair? (car xs))
+            (eq? (caar xs) key)
+            (pair? (cdar xs)))
+       (cadar xs))