android-emulator-build
Android emulator build
Turns the Expo app into a Release APK with one command, npm run build:android:emu -- --variant preview, and proves what was built: JDK 17 and the pinned Android SDK made it, the APK is the variant asked for (test-only code present in development and preview, absent in production; the variant's package; only the permissions the app declared), and the app starts and draws on an emulator this repo owns. No Android Studio, no Metro, no upload key. Verified on 2026-10-14 with Expo SDK 57 (expo 57.0.26), React Native 0.86.3 (compileSdk and targetSdk 36, build tools 36.0.0, Gradle 9.3.1 from the prebuild template), JDK 17.0.11 and Android emulator 35.1.20 on Apple silicon, booting the API 35 16 KB Play Store image (system-images;android-35;google_apis_playstore_ps16k;arm64-v8a) through the contract's systemImage; the pinned default image (system-images;android-36;google_apis;<abi>) has not had a real boot yet. The iOS twin is ios-simulator-build.
Rules that must hold
- Every emulator build runs through one script and starts from a clean prebuild.
scripts/build/build-android-emu.tspreflights every npm script it will run (exit 2, nothing built), thennpx expo prebuild --platform android --clean, the contract's post-prebuild scripts, Gradle, install, launch and screenshot. Why:android/is generated (Continuous Native Generation, owned byexpo-prebuild-and-config-plugins), and a staleandroid/from another variant builds the wrong package. - JDK 17 and the Android SDK are selected per process, and the pins match React Native.
scripts/android/toolchain.tspinsJDK_MAJOR,ANDROID_APIandBUILD_TOOLS_VERSIONas literals equal to the installed React Native's version catalog;selectToolchain()finds JDK 17 by itsreleasefile, checks the SDK packages and setsJAVA_HOMEandANDROID_HOMEon the child environment. Neverjenv global,sdk default java,update-alternativesorsudo. Why: a machine often holds several JDKs (Android Studio's bundled one changes with Studio updates), and a global switch changes every build on it. - The variant is exported once, for the whole run.
variantEnv(variant)fromsrc/config/app-variant.tssetsAPP_VARIANT=EXPO_PUBLIC_APP_VARIANTon the one environment every step gets. Why: the variant enters the APK three times, each in its own process: the prebuild (package, manifest), expo-constants'createExpoConfigGradle task (assets/app.config) and Metro (export:embed, the JS bundle). - Metro's cache is keyed on the public values before any Release build.
metro.config.jssetscacheVersionfrom theEXPO_PUBLIC_*values (the key and its proof belong toapp-variants-and-environment). Why: the build runs withCI=1, so the bundling task keeps Metro's cache, and a production APK built after a preview APK can ship the preview bundle. - The build is
:app:assembleReleasefor one ABI, with no daemon and no upload key../gradlew --no-daemon :app:assembleRelease -PreactNativeArchitectures=<abi>(gradleReleaseArgs), the ABI of the emulator (hostAbi(): arm64-v8a on Apple silicon, x86_64 elsewhere). Why: Release embeds the Hermes bundle users run (Debug loads JS from Metro); one ABI builds about four times faster than the template's four; a daemon would keep another run's environment and 2 GB of memory on a shared machine; the prebuild template signs Release with the public debug keystore, so no secret is touched (upload signing isandroid-release-play). - Emulators are AVDs named
<prefix>-<purpose>, started on a console port this run picked and addressed only by serial.emulator -avd <prefix>-<purpose> -port <even port>, thenadb -s emulator-<port> ...afteradb -s emulator-<port> emu avd nameconfirmed the AVD behind it; only prefixed AVDs are created, stopped (adb -s <serial> emu kill) or deleted. Neveradb -e/-d,adb kill-server,gradlew --stop, a Gradleinstall*task,pkillof emulators oreas build:run -p android. Why: several sessions share one machine and one adb server; each of those reaches, restarts or installs on someone else's device. - Every tooling process runs with
EXPO_NO_TELEMETRY=1andCI=1. Why: no telemetry, and no interactive prompt that would hang an unattended run. - A fresh install and a cold start, then wait on a signal, never a fixed sleep; then check the screenshot and look at it. The previous copy is uninstalled,
am start -S -Wstarts the launcher activity, and the run polls: the process alive (pidof), its activity resumed, the crash buffer empty, and the contract'sreadyLogline or two identical screenshots after at least 3 s. Why: a build that installed can still crash at launch, sit behind a system dialog, or draw a blank screen; only the picture shows the last one. - The built APK proves its variant.
check-apkreads the APK: an embedded Hermes bundle, the test-only sentinel (present in development and preview, absent in production), not debuggable, the variant's own package in the manifest and in expo-constants'assets/app.config, targetSdk 36 and minSdk 24 or higher, native libraries for the emulator ABI with 16 KB aligned segments, a signing block, and for production no placeholder and no cleartext traffic. Why:BUILD SUCCESSFULsays nothing about what shipped inside. - The merged manifest is the permission evidence. The contract's
permissionslists every permission the app may carry (seeded with the five the prebuild template always adds; one named after the app is written${applicationId}.NAME, and the APK's own signature permission from androidx.core needs no entry);check-apkfails on any other in the APK. Why: libraries add permissions at Gradle merge time (an image picker's CAMERA, a template's SYSTEM_ALERT_WINDOW), and neitherapp.config.tsnor the prebuild output shows them. - A change to variants, Metro, test-only code or the build tooling is proven by a preview build and a production build made back to back, without clearing any cache. Why: only that order shows a missing cache key or a broken gate.
- Human steps are asked for, never worked around. Installing a JDK, the SDK packages and a system image, and accepting the SDK licences, are the developer's steps (a licence is a legal agreement); the agent stops and asks. Why: a workaround (another JDK, an auto-accepted licence, a different image) builds with the wrong toolchain and hides the problem.
- Generated and output folders stay out of git:
android/,build/andreports/. Why: the prebuild regeneratesandroid/,build/holds the kept APKs and logs,reports/the screenshots.