/build-android-binary
SkillDev toolsCompile 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.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the /build-android-binary skill
What this skill tells your AI
The instructions your AI receives, as published by microsoft/power-platform-skills in plugins/power-apps-mobile-extension/skills/build-android-binary/SKILL.md and read by ahel’s review.
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
assembleReleasetask 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
d8exist 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-manifestfirst — 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 underppmplugin/staging/android-build/(Step 2). The source the engineer maintains is never touched.
Step 1 — Read shared docs + prereq block
- Read
shared/shared-instructions.mdandshared/ppmplugin-format.md. - Read
ppmplugin/staging/manifest.json. If absent, STOP withNEEDS_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 atppmplugin/staging/android/<dex>, do NOT silently overwrite it — surface it (with its build timestamp) and ask viaAskUserQuestionwhether to replace it:- Source +
manifest.jsonunchanged since the recordedAndroid DEX: builttimestamp → 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.
- Source +
- 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): pickbuild-tools/35*/d8(mac/linux) orbuild-tools\35*\d8.bat(win). Prefer a 35.x build-tools; fall back to the highest available. - Record the absolute path as
$D8and 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
d8exists anywhere → offer to install via<sdkmanager> "build-tools;35.0.0"(<sdkmanager>resolved the same way — search$ANDROID_HOME/cmdline-tools/*/bin/sdkmanagerand$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 legacyimplementation 'com.facebook.react:react-native:+'(or thecompileOnlyvariant of it); rewrite those in the copy tocompileOnly "com.facebook.react:react-android:<rnVersion>"(<rnVersion>frompackage.jsondevDependencies, e.g.0.79.7). Standalone, the host doesn't supply React, and the modern Android coordinate isreact-android(resolves frommavenCentral()).compileOnlyso 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-extensionshould fix it there.b. Pin compileSdk to 35. The standalone build has no managed host build
rootProject.ext, sosafeExtGet('compileSdkVersion', …)uses the control's own fallback (older controls default to 33) — which may be too low: RN 0.79'sreact-androidAAR is built against compileSdk 35, and AGP refuses to let acompileSdk < 35module compile against it. Set the copy'scompileSdkVersionto 35 (and ensureminSdkVersionis ≥ 24, the AAR's floor). This requires AGP 8.x (7.x can't compile against android-35). Seeppmplugin-format.md §5. LeavetargetSdkVersionas-is if already ≥ 35.c. Ensure both
google()andmavenCentral()are inrepositories. -
Add
settings.gradlein 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.propertiesdirectly into the staging copy, pinning both the distribution URL and its checksum, plus thegradle-wrapper.jar+gradlew/gradlew.batscripts:distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78distributionSha256Sumis mandatory, not optional. The first./gradlewinvocation 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 athttps://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 inshared-instructions.md §0moves, and never hand-edit it to make a failing build pass.Verify the wrapper JAR before the first
./gradlew.gradle-wrapper.jaris executed bygradlew, so it needs the same treatment as the distribution: copy it only from a trusted source — the control's own committedandroid/gradle/wrapper/or a verified Gradle install — and verify it against the official Gradle 8.13 wrapper JAR SHA-256 published athttps://downloads.gradle.org/distributions/gradle-8.13-wrapper.jar.sha256.Official checksum (8.13):
81a82aaea5abcc8ff68b3dfcb58b3c3c429378efd98e7433460610fecd7ae45fFail 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 wrapperand the system Gradle is too old for AGP 8.8.2: temporarily movebuild.gradleaside 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.gradleEither way, verify
gradle/wrapper/gradle-wrapper.propertiesends withgradle-8.13-bin.zipand carries thedistributionSha256Sumline. Builds in Step 5 always invoke./gradlew(the pinned wrapper), never the systemgradle. -
Generate
gradle.propertiesin the staging copy (the managed host build supplies these ambiently; standalone has none). Withoutandroid.useAndroidX=truethe 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
@ReactModuleannotation anywhere in the module — it needs static symbolsDexClassLoadercan't see. If present, STOP withBLOCKED: remove @ReactModule annotation (incompatible with DexClassLoader). getName()return value equalsmanifest.jsonreceivers[].nativeModule.- ReactPackage FQN equals
manifest.jsonentrypoints.android.packageClass. - The
ReactPackage(packageClass) has a public no-arg constructor. The wrap runtime instantiates it viagetDeclaredConstructor().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 bareclass <Pascal>Package : ReactPackageis fine (implicit no-arg ctor); aclass <Pascal>Package(...)with a primary-constructor parameter list is NOT. If it has parameters, STOP withBLOCKED: ReactPackage <Pascal>Package must have a public no-arg constructor. - Each
@ReactMethodtakes exactly oneReadableMaprequest param (thenPromise) — the wrap proxy spreads the PCF'sargs: [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 likegetStatus()→(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 apowerapps-native-extensionimport. The bundle is native-only; the wrap host dispatches straight toNativeModules.<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 withBLOCKED: 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 anybuild/inside it is gone. The canonicalandroid/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 / Linuxcd ppmplugin\staging\android-build; .\gradlew.bat :assembleRelease # WindowsOutput:
ppmplugin/staging/android-build/build/outputs/aar/<module>-release.aar. On failure, print the failing task + the most relevant Gradle error line and STOP withBLOCKED: gradle assembleRelease failed — <line>(per the dogfooding lesson: surface the real error, don't swallow it). Common standalone failures and their fixes are pinned inppmplugin-format.md §5(compileSdk too low → missing platform 35; React unresolved → wrong coordinate). Also recognizeCannot add extension with name 'kotlin'→ the copy's Kotlin-plugin application conflicts with its AGP (AGP 9.x bundles Kotlin; manualkotlin-androidthen collides). Rare here since we pin AGP 8.8.2 (which needs explicitkotlin-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.jarand compile to DEX — OS-aware per shared-instructions §5 (d8on macOS/Linux,d8.baton Windows; the Windows SDKbuild-tools/<ver>/folder must be on PATH):macOS / Linux (bash): Invoke
d8by the absolute path$D8resolved in Step 1 (it is usually not on PATH) — do not assume a bared8works.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.dexWindows (PowerShell):
$D8is 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.dexd8warnings of the formType com.facebook.react.* was not foundare expected and benign (RN iscompileOnly) — say so explicitly so the user doesn't read them as errors. Any error (non-warning) fromd8→ STOP with the line. -
Verify
ppmplugin/staging/android/<Pascal>Plugin.dexexists and is non-empty. -
Optional — native
.solibraries. If the module has JNI dependencies, the release AAR carries them underjni/<abi>/*.so. Extract those intoppmplugin/staging/android/lib/<abi>/*.so(<abi>∈arm64-v8a,armeabi-v7a,x86,x86_64) so/assemble-ppmpluginzips them underandroid/lib/— the wrap injector copies each ABI dir into the APK'slib/<abi>/(ppmplugin-format §5). Check withjar 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).
Signals
- GitHub stars
- 859
- Forks
- 176
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
build-android-binary- Source
- github.com/microsoft/power-platform-skills