/build-android-binary
Turns the extension's Android source (android/.../<Pascal>Module.kt + <Pascal>Package.kt) into the DEX binary that the wrap runtime loads at runtime via DexClassLoader. This is the heavyweight, toolchain-dependent step of producing a .ppmplugin: source code in, a runnable <Pascal>Plugin.dex out.
Naming note: this skill runs Gradle's assembleRelease task internally. Don't confuse that with /assemble-ppmplugin, which zips the final bundle — different layers. This skill produces a binary; that one produces the bundle.
Cross-platform: runs on macOS, Linux, and Windows — the Android path has no Mac-only step (JDK 17, Gradle, and Android SDK d8 exist on all three). Every shell command below is given in both bash and PowerShell forms per shared-instructions §5.
Read shared/ppmplugin-format.md §5 — the Android binary requirements this skill enforces.
What this skill does NOT do
- Does not author
manifest.json (run /generate-ppmplugin-manifest first — this skill reads it for cross-checks).
- Does not zip the
.ppmplugin — that's /assemble-ppmplugin.
- Does not build iOS — that's
/build-ios-binary (Mac-only).
- Does not modify the canonical
android/ — all standalone adjustments are made to a throwaway copy under ppmplugin/staging/android-build/ (Step 2). The source the engineer maintains is never touched.
Step 1 — Read shared docs + prereq block
- Read
shared/shared-instructions.md and shared/ppmplugin-format.md.
- Read
ppmplugin/staging/manifest.json. If absent, STOP with NEEDS_CONTEXT: manifest.json missing — run /generate-ppmplugin-manifest first (the build cross-checks against it).
2b. Read the ## ppmplugin (third-party controls) block in .extension-state.md. If a DEX already exists at ppmplugin/staging/android/<dex>, do NOT silently overwrite it — surface it (with its build timestamp) and ask via AskUserQuestion whether to replace it:
- Source +
manifest.json unchanged since the recorded Android DEX: built timestamp → recommend Keep existing (reuse) [default]; also offer Replace (rebuild).
- Source or manifest changed → recommend Replace (rebuild) [default]; also offer Keep existing (note: stale — won't match current source).
On Keep, skip straight to Step 6 (report) using the existing DEX. On Replace, continue the rebuild. Building is always safe to repeat — this gate just respects an artifact you may have produced on purpose.
- Run prereq checks and print the visible block (shared-instructions §9.2). Policy: resolve, don't punt. Each check first tries to satisfy itself — locate a tool by its standard install path (not just PATH), or offer to run a safe install and execute it on
yes (§1.5 auto-fix flow). A check only hard-BLOCKs when it needs something genuinely un-resolvable without a human (e.g. a role grant). "Found but not on PATH" is NOT a block — use the absolute path. Checks:
| Check |
Verify |
Auto-fix |
| JDK 17 |
java -version (17+) |
OS-aware install per shared-instructions §1.5 (offer; user confirms) |
| Gradle (bootstrap only) |
gradle --version — any version is fine; it only generates the pinned wrapper (Step 2.3). A system Gradle isn't used for the build, so don't flag its version here. |
install Gradle (OS-aware) if absent |
Android SDK Build-Tools 35 + d8 |
Locate d8, don't require it on PATH (see below) |
If found anywhere, PASS. Only if no d8 exists at all → auto-fix-on-confirm <sdkmanager> "build-tools;35.0.0". |
| Android platform 35 |
<sdkmanager> --list_installed includes platforms;android-35, or $ANDROID_HOME/platforms/android-35/ exists |
Auto-fix on confirm: run it (see below). |
Locating d8 (do NOT block just because it's not on PATH). A user with Android Studio has d8 installed but rarely on PATH. Resolve it to an absolute path and use that path in Step 5:
command -v d8 / where d8.bat — if on PATH, use it.
- Else search the SDK root (
$ANDROID_HOME, else $ANDROID_SDK_ROOT, else mac default ~/Library/Android/sdk, win default %LOCALAPPDATA%\Android\Sdk): pick build-tools/35*/d8 (mac/linux) or build-tools\35*\d8.bat (win). Prefer a 35.x build-tools; fall back to the highest available.
- Record the absolute path as
$D8 and PASS the check with a note: ✓ d8 found at <path> (will invoke by absolute path). Do not require a PATH edit.
- BLOCK only if no
d8 exists anywhere → offer to install via <sdkmanager> "build-tools;35.0.0" (<sdkmanager> resolved the same way — search $ANDROID_HOME/cmdline-tools/*/bin/sdkmanager and $ANDROID_HOME/tools/bin/sdkmanager, not just PATH).
Installing platform 35 (auto-fix on confirm). When the platform-35 check fails, OFFER to run the install and wait for yes (shared-instructions §1.5 — execute, don't just print):
# if sdkmanager is missing entirely (mac):
brew install --cask android-commandlinetools
# then (yes | … auto-accepts the SDK licenses):
yes | <sdkmanager> "platforms;android-35"
Re-verify after, then proceed. Only STOP if the user declines or the install fails.
Why platform 35 specifically: the standalone build compiles against compileSdk 35 (Step 2), because RN 0.79's react-android AAR is built against compileSdk 35 and AGP forces consumers to compileSdk ≥ 35. That requires platforms;android-35 and AGP 8.x (7.x can't compile against android-35). So 35 is the floor, not "34 or newer."
Step 2 — Stage a standalone build copy (canonical android/ stays pristine)
The repo's android/ is a bare library module meant to be consumed by the managed host build's Gradle: it relies on the host to supply rootProject.ext values (compileSdkVersion, minSdkVersion, kotlin_version, read via safeExtGet(...)) and to put React Native on the classpath. Standalone, none of that exists — so the safeExtGet fallbacks apply and React doesn't resolve. Rather than mutate the canonical android/build.gradle (which would degrade the real source to satisfy a throwaway build), build from a copy.
Copy android/ → ppmplugin/staging/android-build/. Delete + recopy fresh on every run so it never drifts from canonical. Apply these standalone adjustments to the COPY only:
a. Pin React Native. The generator now writes compileOnly "com.facebook.react:react-android:<rnVersion>" directly, so on a freshly scaffolded control this is already correct and the step is a no-op — verify and move on. Older controls carry the legacy implementation 'com.facebook.react:react-native:+' (or the compileOnly variant of it); rewrite those in the copy to compileOnly "com.facebook.react:react-android:<rnVersion>" (<rnVersion> from package.json devDependencies, e.g. 0.79.7). Standalone, the host doesn't supply React, and the modern Android coordinate is react-android (resolves from mavenCentral()). compileOnly so it is never bundled — RN is provided at runtime by the wrap shell. If you had to rewrite, say so: the canonical source is drifting and /debug-extension should fix it there.
b. Pin compileSdk to 35. The standalone build has no managed host build rootProject.ext, so safeExtGet('compileSdkVersion', …) uses the control's own fallback (older controls default to 33) — which may be too low: RN 0.79's react-android AAR is built against compileSdk 35, and AGP refuses to let a compileSdk < 35 module compile against it. Set the copy's compileSdkVersion to 35 (and ensure minSdkVersion is ≥ 24, the AAR's floor). This requires AGP 8.x (7.x can't compile against android-35). See ppmplugin-format.md §5. Leave targetSdkVersion as-is if already ≥ 35.
c. Ensure both google() and mavenCentral() are in repositories.
Add settings.gradle in the copy declaring the library as its own root project:
rootProject.name = "<lower>plugin"
Add a Gradle wrapper pinned to the version RN 0.79 uses with AGP 8.8.2. RN 0.79 pairs AGP 8.8.2 with Gradle 8.13. The wrapper MUST pin 8.13 (AGP 8.8 needs Gradle 8.10.2+; older Gradle fails).
Preferred — write a pre-generated wrapper, skip the bootstrap. Write gradle/wrapper/gradle-wrapper.properties directly into the staging copy, pinning both the distribution URL and its checksum, plus the gradle-wrapper.jar + gradlew/gradlew.bat scripts:
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78
distributionSha256Sum is mandatory, not optional. The first ./gradlew invocation downloads and then executes that archive; HTTPS alone authenticates the host, not the bytes. With the pin, Gradle verifies the distribution and aborts on mismatch — fail-closed. The value above is the SHA-256 Gradle publishes at https://services.gradle.org/distributions/gradle-8.13-bin.zip.sha256; it is a constant of the 8.13 pin, so re-derive it from that endpoint whenever the Gradle version in shared-instructions.md §0 moves, and never hand-edit it to make a failing build pass.
Verify the wrapper JAR before the first ./gradlew. gradle-wrapper.jar is executed by gradlew, so it needs the same treatment as the distribution: copy it only from a trusted source — the control's own committed android/gradle/wrapper/ or a verified Gradle install — and verify it against the official Gradle 8.13 wrapper JAR SHA-256 published at https://downloads.gradle.org/distributions/gradle-8.13-wrapper.jar.sha256.
Official checksum (8.13):
81a82aaea5abcc8ff68b3dfcb58b3c3c429378efd98e7433460610fecd7ae45f
Fail closed:
cd ppmplugin/staging/android-build
EXPECTED_WRAPPER_SHA256=81a82aaea5abcc8ff68b3dfcb58b3c3c429378efd98e7433460610fecd7ae45f
ACTUAL=$(shasum -a 256 gradle/wrapper/gradle-wrapper.jar | cut -d' ' -f1)
[ "$ACTUAL" = "$EXPECTED_WRAPPER_SHA256" ] || {
echo "BLOCKED: gradle-wrapper.jar SHA-256 mismatch — expected $EXPECTED_WRAPPER_SHA256, got $ACTUAL"; exit 1; }
Never fetch either artifact from an unpinned third-party mirror, and never skip the check because the build is "just a throwaway staging copy" — the staging copy runs on the same machine with the same privileges.
Fallback — if you must run gradle wrapper and the system Gradle is too old for AGP 8.8.2: temporarily move build.gradle aside so the wrapper task has nothing to evaluate, generate the wrapper, then restore:
cd ppmplugin/staging/android-build
mv build.gradle build.gradle.tmp
gradle wrapper --gradle-version 8.13 --distribution-type bin \
--gradle-distribution-sha256-sum 20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78
mv build.gradle.tmp build.gradle
Either way, verify gradle/wrapper/gradle-wrapper.properties ends with gradle-8.13-bin.zip and carries the distributionSha256Sum line. Builds in Step 5 always invoke ./gradlew (the pinned wrapper), never the system gradle.
Generate gradle.properties in the staging copy (the managed host build supplies these ambiently; standalone has none). Without android.useAndroidX=true the androidx deps (appcompat, core-ktx) fail resource linking — this is the single thing that turns the Android build from flaky to one-shot:
android.useAndroidX=true
android.enableJetifier=false
org.gradle.jvmargs=-Xmx2048m
No confirmation gate is needed for edits to the throwaway copy (the canonical source is untouched). Just report the standalone adjustments applied (the React coordinate swap + compileSdk 35 + generated gradle.properties) so the user knows how the build env differs from the managed host build's.
Step 3 — Assert DexClassLoader constraints
If the expected Android module / ReactPackage .kt files don't exist where manifest.entrypoints.android.packageClass implies, STOP with NEEDS_CONTEXT: Android module structure missing — see shared/repo-layout.md rather than letting Gradle fail cryptically (the manifest skill's structure preflight normally catches this first; this is the backstop if it was skipped).
Before building, verify the source satisfies the runtime-load constraints (ppmplugin-format §5). Read the Kotlin files and check:
- No
@ReactModule annotation anywhere in the module — it needs static symbols DexClassLoader can't see. If present, STOP with BLOCKED: remove @ReactModule annotation (incompatible with DexClassLoader).
getName() return value equals manifest.json receivers[].nativeModule.
- ReactPackage FQN equals
manifest.json entrypoints.android.packageClass.
- The
ReactPackage (packageClass) has a public no-arg constructor. The wrap runtime instantiates it via getDeclaredConstructor().newInstance() — if the class declares only an arg-ed constructor, the plugin silently fails to load at runtime (Loaded 0 plugin package(s)), which no build error catches. Read the <Pascal>Package.kt: a bare class <Pascal>Package : ReactPackage is fine (implicit no-arg ctor); a class <Pascal>Package(...) with a primary-constructor parameter list is NOT. If it has parameters, STOP with BLOCKED: ReactPackage <Pascal>Package must have a public no-arg constructor.
- Each
@ReactMethod takes exactly one ReadableMap request param (then Promise) — the wrap proxy spreads the PCF's args: [request] positionally, so a method that expands the request into multiple positional params won't receive its data. If a @ReactMethod's signature isn't (request: ReadableMap, promise: Promise)-shaped, surface a WARNING (it may be intentional for a no-arg op like getStatus() → (promise: Promise), but a multi-positional-param method is almost always a dispatch-contract mistake — ppmplugin-format §2).
- No SDK-era imports — the module source carries none of
INativeExtension, INativeOperation, INativeExtensionContext, sendAsync, handleMessageAsync, HermesBytecodeLoader, or a powerapps-native-extension import. The bundle is native-only; the wrap host dispatches straight to NativeModules.<nativeModule>.<method> (ppmplugin-format §2 — Runtime dispatch contract), so an SDK symbol here is leakage that /audit-ppmplugin's DEX scan will reject downstream. Catch it at the source: if present, STOP with BLOCKED: SDK-era symbol '<sym>' in module source — the .ppmplugin is native-only (no INativeExtension/sendAsync layer).
A mismatch here means the manifest and the binary disagree — the call won't reach the module on device. STOP with the specific mismatch rather than building a broken pair.
Step 4 — Pre-flight cleanup
Stale outputs cause confusing collisions. Before building:
- Remove any prior
ppmplugin/staging/android/<Pascal>Plugin.dex.
- The staging copy
ppmplugin/staging/android-build/ was recopied fresh in Step 2, so any build/ inside it is gone. The canonical android/build/ (from prior host builds) is irrelevant now — we never build there.
Step 5 — Build AAR → DEX
Run the build for real and surface output location, not the streaming log (shared-instructions §9):
Assemble the release AAR from the staging copy — OS-aware wrapper invocation per shared-instructions §5:
cd ppmplugin/staging/android-build && ./gradlew :assembleRelease # macOS / Linux
cd ppmplugin\staging\android-build; .\gradlew.bat :assembleRelease # Windows
Output: ppmplugin/staging/android-build/build/outputs/aar/<module>-release.aar. On failure, print the failing task + the most relevant Gradle error line and STOP with BLOCKED: gradle assembleRelease failed — <line> (per the dogfooding lesson: surface the real error, don't swallow it). Common standalone failures and their fixes are pinned in ppmplugin-format.md §5 (compileSdk too low → missing platform 35; React unresolved → wrong coordinate). Also recognize Cannot add extension with name 'kotlin' → the copy's Kotlin-plugin application conflicts with its AGP (AGP 9.x bundles Kotlin; manual kotlin-android then collides). Rare here since we pin AGP 8.8.2 (which needs explicit kotlin-android) and build from the control's own gradle — but if it surfaces, align the copy's Kotlin-plugin application with AGP 8.8.2.
Extract classes.jar and compile to DEX — OS-aware per shared-instructions §5 (d8 on macOS/Linux, d8.bat on Windows; the Windows SDK build-tools/<ver>/ folder must be on PATH):
macOS / Linux (bash):
Invoke d8 by the absolute path $D8 resolved in Step 1 (it is usually not on PATH) — do not assume a bare d8 works.
mkdir -p ppmplugin/staging/android
work=$(mktemp -d)
cp ppmplugin/staging/android-build/build/outputs/aar/<module>-release.aar "$work/"
( cd "$work" && jar xf <module>-release.aar classes.jar && "$D8" --min-api 24 --output . classes.jar )
cp "$work/classes.dex" ppmplugin/staging/android/<Pascal>Plugin.dex
Windows (PowerShell): $D8 is the resolved …\build-tools\35.0.0\d8.bat.
New-Item -ItemType Directory -Force ppmplugin\staging\android | Out-Null
$work = New-Item -ItemType Directory -Force (Join-Path $env:TEMP "ppm-dex")
Copy-Item ppmplugin\staging\android-build\build\outputs\aar\<module>-release.aar $work
Push-Location $work
jar xf <module>-release.aar classes.jar
& $D8 --min-api 24 --output . classes.jar
Pop-Location
Copy-Item (Join-Path $work classes.dex) ppmplugin\staging\android\<Pascal>Plugin.dex
d8 warnings of the form Type com.facebook.react.* was not found are expected and benign (RN is compileOnly) — say so explicitly so the user doesn't read them as errors. Any error (non-warning) from d8 → STOP with the line.
Verify ppmplugin/staging/android/<Pascal>Plugin.dex exists and is non-empty.
Optional — native .so libraries. If the module has JNI dependencies, the release AAR carries them under jni/<abi>/*.so. Extract those into ppmplugin/staging/android/lib/<abi>/*.so (<abi> ∈ arm64-v8a, armeabi-v7a, x86, x86_64) so /assemble-ppmplugin zips them under android/lib/ — the wrap injector copies each ABI dir into the APK's lib/<abi>/ (ppmplugin-format §5). Check with jar tf <module>-release.aar | grep '^jni/'; if there are none (the common case — a pure-Kotlin control), skip this step.
Step 6 — Report + next step
Update the ## ppmplugin (third-party controls) block in .extension-state.md: Android DEX: built <ISO timestamp> (<dex name>). Print a fenced summary with the DEX path and size, then offer next steps via AskUserQuestion (shared-instructions §9.1):
- Run /assemble-ppmplugin (recommended next — zip the manifest + DEX into the
.ppmplugin)
- Re-run /generate-ppmplugin-manifest (if the method list or names changed)
- Stay — I'll inspect the DEX first
When the user picks a Run /… option, immediately invoke that skill via the Skill tool in the same turn (sub-skill invocation, shared-instructions §8 + §9.1 "Execute, don't describe"). Do NOT print a "run it when ready" instruction and stop. Only "Stay" ends the run.
Return DONE with the DEX path, or DONE_WITH_CONCERNS if the @ReactMethod count diverged from the documented operations (Step 1 of the manifest skill flags this).
1---2name: build-android-binary3description: Compile a PAM control's Android Kotlin module into the runtime-loadable DEX for a `.ppmplugin`. Creates a staged Gradle build with the pinned wrapper and `react-android` compile dependency, verifies manifest/module/package alignment and runtime-loading constraints, builds the release AAR, then runs `d8 --min-api 24`. Writes `ppmplugin/staging/android/<PascalName>Plugin.dex` and surfaces actionable Gradle or d8 failures. Requires JDK 17+ and Android SDK Build-Tools 35.0.0; `d8` is located from the SDK and system Gradle is optional. Run after /generate-ppmplugin-manifest and before /assemble-ppmplugin.4---56# /build-android-binary78Turns the extension's Android **source** (`android/.../<Pascal>Module.kt` + `<Pascal>Package.kt`) into the **DEX binary** that the wrap runtime loads at runtime via `DexClassLoader`. This is the heavyweight, toolchain-dependent step of producing a `.ppmplugin`: source code in, a runnable `<Pascal>Plugin.dex` out.910> Naming note: this skill runs Gradle's `assembleRelease` task internally. Don't confuse that with [`/assemble-ppmplugin`](../assemble-ppmplugin/SKILL.md), which zips the final bundle — different layers. This skill produces a *binary*; that one produces the *bundle*.1112> **Cross-platform:** runs on macOS, Linux, **and** Windows — the Android path has no Mac-only step (JDK 17, Gradle, and Android SDK `d8` exist on all three). Every shell command below is given in both bash and PowerShell forms per shared-instructions §5.1314Read [`shared/ppmplugin-format.md`](../../shared/ppmplugin-format.md) §5 — the Android binary requirements this skill enforces.1516## What this skill does NOT do17- Does not author `manifest.json` (run [`/generate-ppmplugin-manifest`](../generate-ppmplugin-manifest/SKILL.md) first — this skill reads it for cross-checks).18- Does not zip the `.ppmplugin` — that's [`/assemble-ppmplugin`](../assemble-ppmplugin/SKILL.md).19- Does not build iOS — that's `/build-ios-binary` (Mac-only).20- Does not modify the canonical `android/` — all standalone adjustments are made to a throwaway copy under `ppmplugin/staging/android-build/` (Step 2). The source the engineer maintains is never touched.2122---2324## Step 1 — Read shared docs + prereq block25261. Read [`shared/shared-instructions.md`](../../shared/shared-instructions.md) and [`shared/ppmplugin-format.md`](../../shared/ppmplugin-format.md).272. Read `ppmplugin/staging/manifest.json`. If absent, STOP with `NEEDS_CONTEXT: manifest.json missing — run /generate-ppmplugin-manifest first` (the build cross-checks against it).282b. Read the `## ppmplugin (third-party controls)` block in `.extension-state.md`. **If a DEX already exists** at `ppmplugin/staging/android/<dex>`, do NOT silently overwrite it — surface it (with its build timestamp) and ask via `AskUserQuestion` whether to **replace** it:29 - Source + `manifest.json` **unchanged** since the recorded `Android DEX: built` timestamp → recommend **Keep existing (reuse)** [default]; also offer **Replace (rebuild)**.30 - Source or manifest **changed** → recommend **Replace (rebuild)** [default]; also offer **Keep existing** (note: stale — won't match current source).31 On **Keep**, skip straight to Step 6 (report) using the existing DEX. On **Replace**, continue the rebuild. Building is always safe to repeat — this gate just respects an artifact you may have produced on purpose.323. Run prereq checks and print the visible block (shared-instructions §9.2). **Policy: resolve, don't punt.** Each check first tries to *satisfy itself* — locate a tool by its standard install path (not just PATH), or offer to run a safe install and execute it on `yes` (§1.5 auto-fix flow). A check only hard-BLOCKs when it needs something genuinely un-resolvable without a human (e.g. a role grant). "Found but not on PATH" is NOT a block — use the absolute path. Checks:3334| Check | Verify | Auto-fix |35|---|---|---|36| JDK 17 | `java -version` (17+) | OS-aware install per shared-instructions §1.5 (offer; user confirms) |37| Gradle (bootstrap only) | `gradle --version` — **any** version is fine; it only generates the pinned wrapper (Step 2.3). A system Gradle isn't used for the build, so don't flag its version here. | install Gradle (OS-aware) if absent |38| Android SDK Build-Tools 35 + `d8` | **Locate `d8`, don't require it on PATH** (see below) | If found anywhere, PASS. Only if no `d8` exists at all → auto-fix-on-confirm `<sdkmanager> "build-tools;35.0.0"`. |39| **Android platform 35** | `<sdkmanager> --list_installed` includes `platforms;android-35`, or `$ANDROID_HOME/platforms/android-35/` exists | **Auto-fix on confirm:** run it (see below). |4041**Locating `d8` (do NOT block just because it's not on PATH).** A user with Android Studio has `d8` installed but rarely on PATH. Resolve it to an absolute path and *use that path* in Step 5:421. `command -v d8` / `where d8.bat` — if on PATH, use it.432. Else search the SDK root (`$ANDROID_HOME`, else `$ANDROID_SDK_ROOT`, else mac default `~/Library/Android/sdk`, win default `%LOCALAPPDATA%\Android\Sdk`): pick `build-tools/35*/d8` (mac/linux) or `build-tools\35*\d8.bat` (win). Prefer a 35.x build-tools; fall back to the highest available.443. Record the absolute path as **`$D8`** and PASS the check with a note: `✓ d8 found at <path> (will invoke by absolute path)`. **Do not require a PATH edit.**454. BLOCK only if no `d8` exists anywhere → offer to install via `<sdkmanager> "build-tools;35.0.0"` (`<sdkmanager>` resolved the same way — search `$ANDROID_HOME/cmdline-tools/*/bin/sdkmanager` and `$ANDROID_HOME/tools/bin/sdkmanager`, not just PATH).4647**Installing platform 35 (auto-fix on confirm).** When the platform-35 check fails, OFFER to run the install and wait for `yes` (shared-instructions §1.5 — execute, don't just print):48```bash49# if sdkmanager is missing entirely (mac):50brew install --cask android-commandlinetools51# then (yes | … auto-accepts the SDK licenses):52yes | <sdkmanager> "platforms;android-35"53```54Re-verify after, then proceed. Only STOP if the user declines or the install fails.5556**Why platform 35 specifically:** the standalone build compiles against `compileSdk 35` (Step 2), because RN 0.79's `react-android` AAR is built against compileSdk 35 and AGP forces consumers to `compileSdk ≥ 35`. That requires `platforms;android-35` and AGP 8.x (7.x can't compile against android-35). So **35 is the floor**, not "34 or newer."5758---5960## Step 2 — Stage a standalone build copy (canonical `android/` stays pristine)6162The repo's `android/` is a bare library module meant to be consumed by **the managed host build's** Gradle: it relies on the host to supply `rootProject.ext` values (`compileSdkVersion`, `minSdkVersion`, `kotlin_version`, read via `safeExtGet(...)`) **and** to put React Native on the classpath. Standalone, none of that exists — so the `safeExtGet` fallbacks apply and React doesn't resolve. Rather than mutate the canonical `android/build.gradle` (which would degrade the real source to satisfy a throwaway build), **build from a copy.**63641. **Copy** `android/` → `ppmplugin/staging/android-build/`. Delete + recopy fresh on every run so it never drifts from canonical. Apply these standalone adjustments to the **COPY only**:6566 a. **Pin React Native.** The generator now writes `compileOnly "com.facebook.react:react-android:<rnVersion>"` directly, so on a freshly scaffolded control this is already correct and the step is a no-op — verify and move on. Older controls carry the legacy `implementation 'com.facebook.react:react-native:+'` (or the `compileOnly` variant of it); rewrite those in the copy to `compileOnly "com.facebook.react:react-android:<rnVersion>"` (`<rnVersion>` from `package.json` devDependencies, e.g. `0.79.7`). Standalone, the host doesn't supply React, and the modern Android coordinate is `react-android` (resolves from `mavenCentral()`). `compileOnly` so it is never bundled — RN is provided at runtime by the wrap shell. If you had to rewrite, say so: the canonical source is drifting and `/debug-extension` should fix it there.6768 b. **Pin compileSdk to 35.** The standalone build has no managed host build `rootProject.ext`, so `safeExtGet('compileSdkVersion', …)` uses the control's own fallback (older controls default to 33) — which may be too low: RN 0.79's `react-android` AAR is built against **compileSdk 35**, and AGP refuses to let a `compileSdk < 35` module compile against it. Set the copy's `compileSdkVersion` to **35** (and ensure `minSdkVersion` is **≥ 24**, the AAR's floor). This requires AGP 8.x (7.x can't compile against android-35). See [`ppmplugin-format.md §5`](../../shared/ppmplugin-format.md). Leave `targetSdkVersion` as-is if already ≥ 35.6970 c. Ensure both `google()` and `mavenCentral()` are in `repositories`.71722. **Add `settings.gradle`** in the copy declaring the library as its own root project:73 ```gradle74 rootProject.name = "<lower>plugin"75 ```763. **Add a Gradle wrapper pinned to the version RN 0.79 uses with AGP 8.8.2.** RN 0.79 pairs AGP 8.8.2 with Gradle **8.13**. The wrapper MUST pin **8.13** (AGP 8.8 needs Gradle 8.10.2+; older Gradle fails).7778 **Preferred — write a pre-generated wrapper, skip the bootstrap.** Write `gradle/wrapper/gradle-wrapper.properties` directly into the staging copy, pinning **both** the distribution URL and its checksum, plus the `gradle-wrapper.jar` + `gradlew`/`gradlew.bat` scripts:7980 ```properties81 distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip82 distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed7883 ```8485 `distributionSha256Sum` is mandatory, not optional. The first `./gradlew` invocation downloads and then **executes** that archive; HTTPS alone authenticates the host, not the bytes. With the pin, Gradle verifies the distribution and aborts on mismatch — fail-closed. The value above is the SHA-256 Gradle publishes at `https://services.gradle.org/distributions/gradle-8.13-bin.zip.sha256`; it is a constant of the 8.13 pin, so re-derive it from that endpoint whenever the Gradle version in [`shared-instructions.md §0`](../../shared/shared-instructions.md) moves, and never hand-edit it to make a failing build pass.8687 **Verify the wrapper JAR before the first `./gradlew`.** `gradle-wrapper.jar` is executed by `gradlew`, so it needs the same treatment as the distribution: copy it only from a trusted source — the control's own committed `android/gradle/wrapper/` or a verified Gradle install — and verify it against the **official Gradle 8.13 wrapper JAR SHA-256** published at `https://downloads.gradle.org/distributions/gradle-8.13-wrapper.jar.sha256`.8889 Official checksum (8.13):9091 ```text92 81a82aaea5abcc8ff68b3dfcb58b3c3c429378efd98e7433460610fecd7ae45f93 ```9495 Fail closed:9697 ```bash98 cd ppmplugin/staging/android-build99 EXPECTED_WRAPPER_SHA256=81a82aaea5abcc8ff68b3dfcb58b3c3c429378efd98e7433460610fecd7ae45f100 ACTUAL=$(shasum -a 256 gradle/wrapper/gradle-wrapper.jar | cut -d' ' -f1)101 [ "$ACTUAL" = "$EXPECTED_WRAPPER_SHA256" ] || {102 echo "BLOCKED: gradle-wrapper.jar SHA-256 mismatch — expected $EXPECTED_WRAPPER_SHA256, got $ACTUAL"; exit 1; }103 ```104105 Never fetch either artifact from an unpinned third-party mirror, and never skip the check because the build is "just a throwaway staging copy" — the staging copy runs on the same machine with the same privileges.106107 **Fallback — if you must run `gradle wrapper`** and the system Gradle is too old for AGP 8.8.2: temporarily move `build.gradle` aside so the wrapper task has nothing to evaluate, generate the wrapper, then restore:108 ```bash109 cd ppmplugin/staging/android-build110 mv build.gradle build.gradle.tmp111 gradle wrapper --gradle-version 8.13 --distribution-type bin \112 --gradle-distribution-sha256-sum 20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78113 mv build.gradle.tmp build.gradle114 ```115 Either way, verify `gradle/wrapper/gradle-wrapper.properties` ends with `gradle-8.13-bin.zip` **and** carries the `distributionSha256Sum` line. Builds in Step 5 always invoke `./gradlew` (the pinned wrapper), never the system `gradle`.1161174. **Generate `gradle.properties`** in the staging copy (the managed host build supplies these ambiently; standalone has none). **Without `android.useAndroidX=true` the androidx deps (appcompat, core-ktx) fail resource linking** — this is the single thing that turns the Android build from flaky to one-shot:118 ```properties119 android.useAndroidX=true120 android.enableJetifier=false121 org.gradle.jvmargs=-Xmx2048m122 ```123124No confirmation gate is needed for edits to the throwaway copy (the canonical source is untouched). Just **report the standalone adjustments applied** (the React coordinate swap + compileSdk 35 + generated `gradle.properties`) so the user knows how the build env differs from the managed host build's.125126---127128## Step 3 — Assert DexClassLoader constraints129130If the expected Android module / `ReactPackage` `.kt` files don't exist where `manifest.entrypoints.android.packageClass` implies, STOP with `NEEDS_CONTEXT: Android module structure missing — see shared/repo-layout.md` rather than letting Gradle fail cryptically (the manifest skill's structure preflight normally catches this first; this is the backstop if it was skipped).131132Before building, verify the source satisfies the runtime-load constraints (ppmplugin-format §5). Read the Kotlin files and check:133134- **No `@ReactModule` annotation** anywhere in the module — it needs static symbols `DexClassLoader` can't see. If present, STOP with `BLOCKED: remove @ReactModule annotation (incompatible with DexClassLoader)`.135- **`getName()`** return value equals `manifest.json` `receivers[].nativeModule`.136- **ReactPackage FQN** equals `manifest.json` `entrypoints.android.packageClass`.137- **The `ReactPackage` (packageClass) has a public no-arg constructor.** The wrap runtime instantiates it via `getDeclaredConstructor().newInstance()` — if the class declares only an arg-ed constructor, the plugin silently fails to load at runtime (`Loaded 0 plugin package(s)`), which no build error catches. Read the `<Pascal>Package.kt`: a bare `class <Pascal>Package : ReactPackage` is fine (implicit no-arg ctor); a `class <Pascal>Package(...)` with a primary-constructor parameter list is NOT. If it has parameters, STOP with `BLOCKED: ReactPackage <Pascal>Package must have a public no-arg constructor`.138- **Each `@ReactMethod` takes exactly one `ReadableMap` request param** (then `Promise`) — the wrap proxy spreads the PCF's `args: [request]` positionally, so a method that expands the request into multiple positional params won't receive its data. If a `@ReactMethod`'s signature isn't `(request: ReadableMap, promise: Promise)`-shaped, surface a WARNING (it may be intentional for a no-arg op like `getStatus()` → `(promise: Promise)`, but a multi-positional-param method is almost always a dispatch-contract mistake — [ppmplugin-format §2](../../shared/ppmplugin-format.md)).139- **No SDK-era imports** — the module source carries none of `INativeExtension`, `INativeOperation`, `INativeExtensionContext`, `sendAsync`, `handleMessageAsync`, `HermesBytecodeLoader`, or a `powerapps-native-extension` import. The bundle is native-only; the wrap host dispatches straight to `NativeModules.<nativeModule>.<method>` ([ppmplugin-format §2](../../shared/ppmplugin-format.md) — *Runtime dispatch contract*), so an SDK symbol here is leakage that `/audit-ppmplugin`'s DEX scan will reject downstream. Catch it at the source: if present, STOP with `BLOCKED: SDK-era symbol '<sym>' in module source — the .ppmplugin is native-only (no INativeExtension/sendAsync layer)`.140141A mismatch here means the manifest and the binary disagree — the call won't reach the module on device. STOP with the specific mismatch rather than building a broken pair.142143---144145## Step 4 — Pre-flight cleanup146147Stale outputs cause confusing collisions. Before building:148- Remove any prior `ppmplugin/staging/android/<Pascal>Plugin.dex`.149- The staging copy `ppmplugin/staging/android-build/` was recopied fresh in Step 2, so any `build/` inside it is gone. The canonical `android/build/` (from prior host builds) is irrelevant now — we never build there.150151---152153## Step 5 — Build AAR → DEX154155Run the build for real and surface output location, not the streaming log (shared-instructions §9):1561571. **Assemble the release AAR** from the staging copy — OS-aware wrapper invocation per shared-instructions §5:158 ```bash159 cd ppmplugin/staging/android-build && ./gradlew :assembleRelease # macOS / Linux160 ```161 ```powershell162 cd ppmplugin\staging\android-build; .\gradlew.bat :assembleRelease # Windows163 ```164 Output: `ppmplugin/staging/android-build/build/outputs/aar/<module>-release.aar`. On failure, print the failing task + the most relevant Gradle error line and STOP with `BLOCKED: gradle assembleRelease failed — <line>` (per the dogfooding lesson: surface the real error, don't swallow it). Common standalone failures and their fixes are pinned in [`ppmplugin-format.md §5`](../../shared/ppmplugin-format.md) (compileSdk too low → missing platform 35; React unresolved → wrong coordinate). Also recognize `Cannot add extension with name 'kotlin'` → the copy's Kotlin-plugin application conflicts with its AGP (AGP 9.x bundles Kotlin; manual `kotlin-android` then collides). Rare here since we pin AGP 8.8.2 (which *needs* explicit `kotlin-android`) and build from the control's own gradle — but if it surfaces, align the copy's Kotlin-plugin application with AGP 8.8.2.1651662. **Extract `classes.jar` and compile to DEX** — OS-aware per shared-instructions §5 (`d8` on macOS/Linux, `d8.bat` on Windows; the Windows SDK `build-tools/<ver>/` folder must be on PATH):167168 macOS / Linux (bash):169 Invoke `d8` by the **absolute path `$D8` resolved in Step 1** (it is usually not on PATH) — do not assume a bare `d8` works.170171 ```bash172 mkdir -p ppmplugin/staging/android173 work=$(mktemp -d)174 cp ppmplugin/staging/android-build/build/outputs/aar/<module>-release.aar "$work/"175 ( cd "$work" && jar xf <module>-release.aar classes.jar && "$D8" --min-api 24 --output . classes.jar )176 cp "$work/classes.dex" ppmplugin/staging/android/<Pascal>Plugin.dex177 ```178179 Windows (PowerShell): `$D8` is the resolved `…\build-tools\35.0.0\d8.bat`.180 ```powershell181 New-Item -ItemType Directory -Force ppmplugin\staging\android | Out-Null182 $work = New-Item -ItemType Directory -Force (Join-Path $env:TEMP "ppm-dex")183 Copy-Item ppmplugin\staging\android-build\build\outputs\aar\<module>-release.aar $work184 Push-Location $work185 jar xf <module>-release.aar classes.jar186 & $D8 --min-api 24 --output . classes.jar187 Pop-Location188 Copy-Item (Join-Path $work classes.dex) ppmplugin\staging\android\<Pascal>Plugin.dex189 ```190191 `d8` warnings of the form `Type com.facebook.react.* was not found` are **expected and benign** (RN is `compileOnly`) — say so explicitly so the user doesn't read them as errors. Any *error* (non-warning) from `d8` → STOP with the line.1921933. Verify `ppmplugin/staging/android/<Pascal>Plugin.dex` exists and is non-empty.1941954. **Optional — native `.so` libraries.** If the module has JNI dependencies, the release AAR carries them under `jni/<abi>/*.so`. Extract those into `ppmplugin/staging/android/lib/<abi>/*.so` (`<abi>` ∈ `arm64-v8a`, `armeabi-v7a`, `x86`, `x86_64`) so `/assemble-ppmplugin` zips them under `android/lib/` — the wrap injector copies each ABI dir into the APK's `lib/<abi>/` ([`ppmplugin-format §5`](../../shared/ppmplugin-format.md)). Check with `jar tf <module>-release.aar | grep '^jni/'`; if there are none (the common case — a pure-Kotlin control), skip this step.196197---198199## Step 6 — Report + next step200201Update the `## ppmplugin (third-party controls)` block in `.extension-state.md`: `Android DEX: built <ISO timestamp> (<dex name>)`. Print a fenced summary with the DEX path and size, then offer next steps via `AskUserQuestion` (shared-instructions §9.1):202203- **Run /assemble-ppmplugin** (recommended next — zip the manifest + DEX into the `.ppmplugin`)204- **Re-run /generate-ppmplugin-manifest** (if the method list or names changed)205- **Stay — I'll inspect the DEX first**206207**When the user picks a `Run /…` option, immediately invoke that skill via the `Skill` tool in the same turn** (sub-skill invocation, shared-instructions §8 + §9.1 "Execute, don't describe"). Do NOT print a "run it when ready" instruction and stop. Only "Stay" ends the run.208209Return `DONE` with the DEX path, or `DONE_WITH_CONCERNS` if the `@ReactMethod` count diverged from the documented operations (Step 1 of the manifest skill flags this).