plugins/power-apps-mobile-extension/skills/build-android-binary/SKILL.md
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.
npx skillsauth add microsoft/power-platform-skills build-android-binaryInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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.
manifest.json (run /generate-ppmplugin-manifest first — this skill reads it for cross-checks)..ppmplugin — that's /assemble-ppmplugin./build-ios-binary (Mac-only).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.shared/shared-instructions.md and shared/ppmplugin-format.md.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:
manifest.json unchanged since the recorded Android DEX: built timestamp → recommend Keep existing (reuse) [default]; also offer Replace (rebuild).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.$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.$D8 and PASS the check with a note: ✓ d8 found at <path> (will invoke by absolute path). Do not require a PATH edit.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."
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.
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:
@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.manifest.json entrypoints.android.packageClass.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.@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).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.
Stale outputs cause confusing collisions. Before building:
ppmplugin/staging/android/<Pascal>Plugin.dex.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.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.
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):
.ppmplugin)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).
development
(Preview) Builds and edits a model-driven Power Apps app from a natural-language intent — tables, columns, relationships, adaptive forms with sub-grids, views, Choice-column charts, generative page intents for overview/dashboard surfaces (page `.tsx` generated in generate-pages after plan approval), and an app module + sitemap — via the headless cds-maker-sdk. Runs an interactive, multi-turn authoring flow (env selection, jobs-to-be-done first, then design-only App Spec authoring across confirmed levels, guardrail lint, plan-mode approval, generate-pages, full build) and a narrated build, and can download a deployed app back into an editable spec to change it. Use when the user says "build an app for X", "create a model-driven app", "make me an app to manage Y", or "edit/add to my app". This skill stands alone and does not require /genpage — but for a standalone generative page added to an app that already exists, use /genpage instead.
data-ai
Use when the user wants to enable offline mode for a Power Apps mobile app and create a Mobile Offline Profile in Dataverse — designs per-table row scope, relationships, columns, and sync frequency through a 3-gate approval flow.
data-ai
Use when the user wants to design or redesign the Dataverse schema and connector plan for an existing mobile app, or has an ER diagram (image, Mermaid, or text) to apply. Skip when the user is creating a brand-new app — /create-mobile-app handles the data model inline.
tools
Use when the user wants to report a bug, file an issue, submit a bug report, or report any problem with the mobile-app plugin.