6 min read

Building Android apps on Asahi Linux

How to get the Android Gradle Plugin's aapt2 running on aarch64 Asahi Linux through QEMU, and a working adb, so you can actually develop Android apps on Apple Silicon.

AndroidAsahi LinuxQEMUaarch64

Asahi Linux runs beautifully on Apple Silicon until you try to build an Android app. The Java, the Gradle daemon, the Kotlin compiler, all of that works. Then the build fails on a single binary called aapt2 with an error that looks like the file is corrupt, when really it’s just the wrong architecture.

This is the setup I use to develop Android apps on an M-series Mac running Asahi Linux. It wraps Google’s x86-64 aapt2 with QEMU user-mode emulation, and swaps the SDK’s adb for a native ARM64 build. By the end you can run ./gradlew assembleDebug and adb install like on any other machine.

Why aapt2 breaks on ARM64

aapt2 (Android Asset Packaging Tool 2) is the tool the Android Gradle Plugin (AGP) uses to compile and link your resources: the XML layouts, drawables, strings, everything in res/. It generates R.java and the compiled resource table that goes into the APK. No aapt2, no APK.

Google distributes aapt2 only as an x86-64 ELF binary. It comes down from Maven as com.android.tools.build:aapt2 and also ships in the SDK build-tools. On an aarch64 machine that binary won’t execute directly.

AGP tries to be clever about this. If your system has binfmt_misc configured to dispatch x86-64 binaries to QEMU automatically, AGP will sometimes just work. In practice that path is fragile: the dispatcher may not be set up, the version resolution can be inconsistent, and on a fresh Asahi install the build just hangs or fails with a confusing message about a “broken” executable. Rather than fight binfmt_misc, I run QEMU explicitly through a small wrapper. It’s deterministic, it’s fast (~0.07s per call), and it survives AGP upgrades.

What you need

You’re on Asahi Linux, which is Fedora-based, so the package names below are for dnf. I’ve added the Debian/Ubuntu and Arch equivalents where they differ.

  • qemu-user (provides qemu-x86_64) for the emulation layer.
  • erofs-utils (provides erofsfuse) to mount the x86-64 root filesystem.
  • FEX-Emu’s rootfs, which is a ready-made x86-64 sysroot. Install FEX and its rootfs, and you get a file at /usr/share/fex-emu/RootFS/default.erofs containing lib64/ld-linux-x86-64.so.2 and the glibc that aapt2 links against. (Any x86-64 rootfs with that dynamic linker works if you don’t want FEX; see the last section.)

Step 1: install the dependencies

sudo dnf install -y qemu-user erofs-utils

Debian/Ubuntu: sudo apt install qemu-user-static erofs-utils Arch: sudo pacman -S qemu-user-static erofs-utils

Then install FEX and pull its rootfs through FEX’s own setup tool, so the default.erofs image exists at the path above. Confirm QEMU is on your path:

qemu-x86_64 --version

Step 2: get the wrapper setup script

I keep this in a tiny repo at github.com/IgnacioLD/aapt2-qemu. Download it and, as always with a script you found online, read it before you run it:

curl -sLO https://raw.githubusercontent.com/IgnacioLD/aapt2-qemu/main/aapt2-qemu-setup.sh
less aapt2-qemu-setup.sh

There’s no magic in it. In order, it:

  1. Verifies qemu-x86_64 is available.
  2. FUSE-mounts the FEX rootfs (erofsfuse) at /tmp/aapt2-x86root as the x86-64 sysroot, and checks it contains lib64/ld-linux-x86-64.so.2.
  3. Searches ~/.gradle/caches for the newest x86-64 aapt2 AGP already downloaded.
  4. Writes a small wrapper at ~/.local/share/aapt2-qemu/aapt2 that runs qemu-x86_64 -L <sysroot> <aapt2> "$@".
  5. Sets android.aapt2FromMavenOverride in ~/.gradle/gradle.properties so AGP uses the wrapper.

Step 3: run it

sh aapt2-qemu-setup.sh

If it complains that no aapt2 was found in the Gradle cache, run the build once first so AGP downloads it:

./gradlew assembleDebug   # this will fail when it tries to execute aapt2, that's fine
sh aapt2-qemu-setup.sh   # now it finds the binary and installs the wrapper

The FUSE mount is ephemeral, so you need to run the script once per session (or after a reboot). The Gradle property it writes is permanent.

Step 4: build

./gradlew assembleDebug

That should now compile end to end. Resources get processed through the QEMU-wrapped aapt2 and land in your APK.

adb: don’t use the SDK one

This trips people up. The adb that ships with the Android SDK at $ANDROID_HOME/platform-tools/adb is also x86-64. On Asahi it won’t run natively. Android Studio will happily tell you adb is broken for reasons you can’t quite trace.

Install the native ARM64 adb from your distro instead:

sudo dnf install android-tools

Debian/Ubuntu: sudo apt install adb Arch: sudo pacman -S android-tools

This gives you /usr/bin/adb, a real native aarch64 build. Make sure it comes first on your PATH, ahead of the SDK copy, so both the command line and Android Studio pick it up. You can check which one resolves:

which adb      # should be /usr/bin/adb
file $(which adb)   # should say aarch64, ARM, not x86-64

Then connect a device over USB or set up wireless debugging, and verify:

adb devices

If your phone shows up there, you have the full loop: build with ./gradlew, deploy with adb install, read logs with adb logcat. All native, none of it relayed through an x86-64 emulator.

Why the wrapper has a weird stderr rule

This bit is worth knowing if you ever debug it. AGP talks to aapt2 as a long-lived daemon. There are two behaviours the wrapper has to get exactly right, or AGP silently gives up:

  • For aapt2 version, aapt2 prints its version banner to stderr, but AGP reads the version from stdout. So the wrapper merges stderr into stdout for that one subcommand.
  • For everything else, the wrapper leaves stderr alone. AGP holds the daemon’s stderr pipe open and treats EOF on it as a crash. If you redirect stderr, AGP concludes aapt2 died.

Also, the wrapper’s filename has to end in aapt2. AGP validates the override path with path.endsWith("aapt2") and rejects anything else. Trivial, but it cost me an afternoon.

Customising paths

The script reads three optional environment variables, all with sane defaults:

Variable Default Purpose
AAPT2_EROFS /usr/share/fex-emu/RootFS/default.erofs The FEX rootfs image
AAPT2_SYSROOT /tmp/aapt2-x86root Where to mount it
AAPT2_WRAPPER_DIR $HOME/.local/share/aapt2-qemu Where the wrapper lives
AAPT2_SYSROOT=/custom/mount ./aapt2-qemu-setup.sh

Without FEX

You don’t actually need FEX. The script only needs an x86-64 sysroot containing lib64/ld-linux-x86-64.so.2 and the libraries aapt2 links against. If you point AAPT2_EROFS at a plain directory instead of an erofs image, the script skips the FUSE mount and uses the directory directly:

AAPT2_EROFS=/path/to/some-x86-rootfs ./aapt2-qemu-setup.sh

Any Debian/Ubuntu x86-64 rootfs, a Docker image extracted to a folder, or a manual extraction will do.

Wrapping up

That’s the whole setup: QEMU wraps aapt2, the distro android-tools gives you a native adb, and Asahi stops being an awkward second-class platform for Android development. The emulation overhead on aapt2 is negligible because resource processing is a tiny fraction of a build, and everything else, Gradle, the JDK, the toolchain, runs natively.

If you hit a snag, the repo has the full script and README. Happy hacking on ARM.