Building Android APKs on an Android Phone (with claude code)

coding on phones

Below is a claude code ‘sonnet’ ,if you will, about how to use claude code running on termux android emulator app for building and compiling and android app. The android sdk does work on termux although I am not sure about adb. Android debug bridge is nice for agents to iterate and troubleshoot autonomously. I would love to build an android app for developing android apps. There are such projects but they dont quite work all the time and also dont support ai agents. I built a libgdx game called ‘Balloon Pop‘ using claude code and this setup. Claude code actually came up with this so here it is:

There is something pleasingly recursive about compiling an Android app on an Android phone. No laptop, no CI runner, no sudo — just Termux, a proot-distro Debian container, and a gradlew script that has no idea it’s running on the same hardware it’s building for.

It works. It just doesn’t work out of the box, and the failure modes are unusual enough that you can burn an hour rediscovering them from first principles. Here’s the shape of the problem and the four things you actually have to solve.

The environment

Termux gives you a Linux userland on Android without root. proot-distro layers a real Debian rootfs on top of that, which gets you apt, familiar paths, and enough of a filesystem that Gradle stops complaining. What you don’t get:

  • Root. apt install openjdk-21-jdk dies with “requested operation requires superuser privilege.” There is no way around this. There is a way through it.
  • A system JDK. Related to the above.
  • An architecture the Android toolchain fully supports. This is the interesting one.

Everything else — Gradle, the SDK, your project — behaves normally once you get past those.

Hurdle 1: a JDK without a package manager

You don’t need apt to install a JDK. You need a tarball and somewhere to put it. Adoptium publishes prebuilt aarch64 Linux binaries:

mkdir -p ~/tools && cd ~/tools
curl -sL "https://api.adoptium.net/v3/binary/latest/21/ga/linux/aarch64/jdk/hotspot/normal/eclipse" -o jdk21.tar.gz
tar xzf jdk21.tar.gz

Then point at it:

export JAVA_HOME=~/tools/jdk-21.x.x+y
export PATH="$JAVA_HOME/bin:$PATH"

Check uname -m before you commit to that URL — swap aarch64 for x64 if you’re somehow not on ARM.

The one thing worth being deliberate about is where you extract it. Put it under a stable path like ~/tools, not a scratch directory. A JDK you have to re-download every session is a JDK you will grow to resent.

Hurdle 2: the SDK

Nothing exotic here — the command-line tools zip unpacks and runs fine, no root required:

mkdir -p ~/android-sdk/cmdline-tools
cd /tmp && curl -sL -o cmdline-tools.zip \
  "https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip"
unzip -q cmdline-tools.zip
mv cmdline-tools ~/android-sdk/cmdline-tools/latest

Then accept licenses and pull whatever your build.gradle asks for — platform-tools, the right platforms;android-N, matching build-tools. Point the project at it with a one-line local.properties:

echo "sdk.dir=/path/to/android-sdk" > local.properties

Two small things that cost turns if you don’t know them:

Environment variables don’t persist. If you’re driving this through a tool harness where each command is its own invocation, JAVA_HOME and PATH evaporate between calls. Export them at the top of every script, not once at the start of the session.

Invoke scripts via sh, not ./. More on why in a moment, but sh gradlew assembleDebug and sh sdkmanager ... will save you a category of mystery permission errors.

Hurdle 3: aapt2 is x86_64-only

This is the real blocker, and it’s the one that makes this post worth writing.

You’ll get all the way to resource processing and hit:

> Failed to transform core-1.x.x.aar ... AarResourcesCompilerTransform
   > AAPT2 aapt2-... Daemon #0: Daemon startup failed

The cause is exactly what it looks like once you check:

find ~/.gradle/caches -path '*/transformed/aapt2-*-linux/aapt2' -exec file {} \;
# ... ELF 64-bit LSB pie executable, x86-64 ...

Google publishes aapt2 for recent AGP versions as an x86_64 Linux binary. Only. There is no aarch64 build. Your phone is aarch64. The Android build toolchain, on Android hardware, cannot run its own resource compiler.

The fix is user-mode emulation. Termux ships qemu-x86_64, which will happily run an x86_64 ELF — it just needs an x86_64 glibc to dynamically link against. You can assemble a minimal sysroot out of raw .deb files without root, because dpkg-deb -x is just an unpacker:

mkdir -p ~/tools/amd64root && cd ~/tools/amd64root
curl -sLO "http://deb.debian.org/debian/pool/main/g/glibc/libc6_<ver>_amd64.deb"
curl -sLO "http://deb.debian.org/debian/pool/main/g/gcc-14/libgcc-s1_<ver>_amd64.deb"

mkdir -p sysroot
dpkg-deb -x libc6_*_amd64.deb sysroot
dpkg-deb -x libgcc-s1_*_amd64.deb sysroot

# usrmerge symlinks aren't in the raw .deb contents — add them by hand
ln -sfn usr/lib64 sysroot/lib64
ln -sfn usr/lib   sysroot/lib

Match the versions to what’s installed for the host arch (dpkg -l libc6 libgcc-s1), and use readelf -d on the aapt2 binary if you want to confirm exactly which libraries it’s asking for.

Test the sysroot against the real binary before touching anything in the Gradle cache:

qemu-x86_64 -L ~/tools/amd64root/sysroot "$REAL_AAPT2" version

If that prints a version string, you’re done thinking and just have to wire it up. Rename the cached binary and drop a wrapper script in its place:

mv "$AAPT2_DIR/aapt2" "$AAPT2_DIR/aapt2.real"
cat > "$AAPT2_DIR/aapt2" <<EOF
#!/bin/sh
exec $QEMU -L $HOME/tools/amd64root/sysroot "$AAPT2_DIR/aapt2.real" "\$@"
EOF
chmod +x "$AAPT2_DIR/aapt2"

Wrap the Maven-cached copy, not the SDK’s build-tools/<ver>/aapt2. AGP resolves its own aapt2 through Maven for the resource tasks that actually fail; the build-tools copy typically never gets invoked during a normal assembleDebug. Wrapping it is wasted effort.

The wrapper sticks across rebuilds — Gradle’s transform cache won’t re-run and clobber it just because you built again. It does not survive a fresh container. If a later session hits the same daemon-startup error, that’s the tell.

Hurdle 4: chmod silently lies

The last oddity, and a genuinely disorienting one. Files under the SDK directory and inside project repos are often owned by a different uid than your shell user — something like 10310:1023 versus your own 1000. Reads and writes still work, courtesy of whatever overlay or bind mount Android has arranged. But chmod on those files silently no-ops. Exit code 0, no error, mode unchanged.

So you chmod +x a script, run ./script, and get Permission denied — which reads like a filesystem bug until you know what’s happening. Two rules cover it:

  • Run scripts you don’t own through sh script.sh and sidestep the executable bit entirely.
  • Create new executables somewhere you actually own — ~/.gradle~/tools, a scratch dir. That’s why the aapt2 wrapper lands under ~/.gradle and the chmod +x there works fine.

The short version

For future-me, or anyone landing here from a search:

  1. command -v java empty → portable JDK tarball into ~/tools.
  2. No SDK → unpack cmdline-tools, sdkmanager the pieces your build.gradle names.
  3. No local.properties → one line, sdk.dir=....
  4. Build dies at processDebugResources with “Daemon startup failed” → qemu wrapper around the Maven-cached aapt2.
  5. chmod +x didn’t take → wrong uid, use sh script or write to a path you own.

Then sh gradlew assembleDebug --console=plain, and the APK shows up at <module>/build/outputs/apk/debug/.

It’s a pile of workarounds, sure. But the fact that the pile is finite — four problems, none of them requiring root — is the actual story. A phone is a perfectly good build machine. It just needs to be told, in fairly specific terms, that x86 is not the only architecture in the world.


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *