tally/android/SETUP.md

9.5 KiB

Android setup, emulator and deployment

Everything here is per-user and removable. Nothing is installed system-wide, no package manager outside mise is involved, and no NDK is needed at any point.

What installs what

It is not mise alone, and the distinction matters when something breaks.

Layer Provided by Pinned? Lives in
Zig, JDK, Gradle, SDK command-line tools mise, from .mise.toml yes, by version ~/.local/share/mise/installs/
SDK packages: platforms, build-tools, platform-tools, emulator, system images sdkmanager, via the mise tasks below partly - see below inside the mise android-sdk install directory
The AVD (the emulator's virtual device) avdmanager, via a mise task n/a .tmp/avd in this repo, gitignored

mise install is necessary but not sufficient. It gives you sdkmanager; it does not give you an SDK you can build against. mise has no idea the SDK packages exist, so they are not captured by mise install on another machine and are lost if the android-sdk tool is reinstalled. The four tasks below exist to make that step repeatable, and they are the answer to "is it mise only": it is now, in the sense that every command is a mise task, but the pinning is weaker than the [tools] table.

platforms;android-35, build-tools;35.0.0 and the system image are pinned by exact version. platform-tools and emulator have no version component in their package ids and always resolve to whatever is current - the one genuinely unpinned thing here.

Disk cost, which is not small: about 450MB for the build packages, and about 4.6GB more if you want the emulator (821MB emulator, 3.8GB system image).

One-time setup

mise install                 # Zig, JDK 21, Gradle 8.14.5, SDK command-line tools
mise run android-sdk         # platform-tools, platforms;android-35, build-tools;35.0.0

Add the emulator only if you want one; a physical device needs neither of these:

mise run android-emulator    # emulator + 16KB-page system image, ~4.6GB
mise run android-avd         # creates the tally35 AVD in .tmp/avd

The system image is system-images;android-35;google_apis_ps16k;x86_64, chosen on purpose. ps16k means 16KB memory pages, which Android 15 introduced and which zig build android aligns the shared libraries for. It is the stricter target: 16KB alignment is also valid on a 4KB device, so a library that loads there loads anywhere. A libc-free library is more likely to break on it than on anything else, which is the point.

Build

zig build android                      # native libs, one .so per ABI, into zig-out/android
cd android && gradle assembleDebug     # packages them into the APK

The APK lands at android/app/build/outputs/apk/debug/app-debug.apk.

Gradle never invokes Zig. jniLibs.srcDirs points at zig-out/android, so Gradle only packages whatever is sitting there. After changing engine or JNI code you must run zig build android yourself, or Gradle will cheerfully package a stale library and the app will run old arithmetic.

Run it in the emulator

Two steps, because booting the emulator and installing the app are separate. A freshly booted AVD has no Tally on it.

mise run android-run &                 # boots tally35; `adb emu kill` to stop
mise run android-install               # builds libs + APK, waits for boot, installs, launches

android-install re-runs zig build android every time on purpose, so the APK never carries a stale library. It waits for sys.boot_completed, so it is safe to start while the emulator is still booting. It works the same against a phone.

The app goes missing in two situations, both expected:

  • mise run android-avd recreates the AVD with --force, which wipes its storage.
  • gradle connectedAndroidTest uninstalls the app when it finishes (AGP's default).

Either way, mise run android-install puts it back.

The manual equivalent, if you want the pieces:

adb wait-for-device
adb shell getprop sys.boot_completed   # wait until this prints 1
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
adb shell am start -n dev.lerch.tally/.MainActivity

For a headless run - useful over ssh, or when you only want the instrumented tests:

mise run android-run -- -no-window

Hardware acceleration needs /dev/kvm readable, which in practice means being in the kvm group (id -nG | grep kvm). Without it the emulator falls back to software rendering and is unusably slow rather than broken.

If you run the emulator outside the mise task, export the AVD location first or it will not find tally35, because it is deliberately not in ~/.android:

export ANDROID_AVD_HOME="$PWD/.tmp/avd"

Deploy to a phone over USB

On the phone: Settings > About phone > tap Build number seven times, then Settings > System > Developer options > USB debugging on.

adb devices                            # the phone appears; accept the prompt on its screen
mise run android-install               # or: adb install -r <apk> && adb shell am start ...

Until you accept the "Allow USB debugging?" prompt on the phone, adb devices lists it as unauthorized and every other command fails. The APK is signed with the local debug keystore (~/.android/debug.keystore), which is fine for sideloading onto your own device and not for distribution.

Deploy to a phone over Wi-Fi

Yes, and there are two mechanisms. The modern one needs no cable at all.

Wireless debugging, Android 11 and later

On the phone: Developer options > Wireless debugging > on. Tap Pair device with pairing code. It shows a six-digit code and an ip:port for pairing - note that this port is different from the one on the main Wireless debugging screen, which is the one you connect to afterwards.

adb pair 192.168.1.50:37419             # the pairing ip:port; it prompts for the code
adb connect 192.168.1.50:42133          # the ip:port from the Wireless debugging screen
adb devices                             # shows 192.168.1.50:42133  device

Then install and am start exactly as over USB. Pairing is remembered, so later sessions need only adb connect; the port changes on reboot, so re-read it from the phone. adb disconnect when finished.

adb pair exists in platform-tools 37.0.1, which is what mise run android-sdk installs. Verify with adb --help | grep pair if in doubt.

adb over TCP, any version, needs one cable first

adb tcpip 5555                          # over USB; restarts adbd listening on TCP
                                        # unplug the cable now
adb connect 192.168.1.50:5555

This is the older path and it is worth knowing what it gives up: it is unauthenticated beyond the existing adb key and stays open on a fixed port until the phone reboots or you run adb usb. On a network you do not control, prefer adb pair.

Both paths need the phone and the computer on the same network, with client isolation off

  • many guest networks and some mesh setups block the connection silently.

Tests

zig build test                          # 965 host tests, including the C ABI
zig build jvm-test                      # proves the JNI table against a desktop JVM, no device
cd android && gradle connectedAndroidTest   # 10 instrumented tests, needs a device or emulator

gradle connectedAndroidTest uninstalls both APKs when it finishes, which is AGP's default. Reinstall before launching the app by hand, or you get Activity class ... does not exist.

When it goes wrong

Error: An error occurred while creating AVD: ~/.android/avd - on this machine ~/.android/avd is a dangling symlink into a Flatpak Android Studio config directory that no longer exists. avdmanager follows it, fails, and reports the path with no explanation. The android-avd task sidesteps it with ANDROID_AVD_HOME; deleting the dead symlink also works.

UnsatisfiedLinkError: dlopen failed: cannot locate symbol ... - the native library referenced something Bionic provides and this library does not link. See design 6.3; the audit is zero DT_NEEDED, zero undefined dynamic symbols, no TLS segment, per ABI:

readelf -d  zig-out/android/arm64-v8a/libtally.so | grep NEEDED    # expect nothing
readelf --dyn-syms -W zig-out/android/arm64-v8a/libtally.so | awk '$7=="UND"'

Check all three ABIs. One instance of this reproduced on arm64-v8a alone, so an x86_64 emulator passed while phones would have failed.

The app runs but answers look stale - zig build android was not re-run, so the APK carries an old libtally.so.

Nothing is wrong but you want to know the boundary is alive - the title bar reads Tally 0.1.0, and that string comes from tally_version through JNI. If the title renders, the library loaded and a string round-tripped.

Logs:

adb logcat -d | grep -iE 'tally|UnsatisfiedLink|FATAL'

Which library a device actually loaded, and whether it has 16KB pages:

adb shell getprop ro.product.cpu.abi
adb shell getconf PAGE_SIZE

Removing all of it

adb emu kill                            # stop a running emulator
rm -rf .tmp/avd                         # the virtual device
mise run android-emulator --help        # (no uninstall task; use sdkmanager directly)
sdkmanager --uninstall "emulator" "system-images;android-35;google_apis_ps16k;x86_64"
mise uninstall java gradle android-sdk  # the tools themselves

Nothing outside ~/.local/share/mise, ~/.android and this repo's .tmp/ is touched. ~/.android holds the adb key and debug keystore and is shared with any other Android tooling on the machine, so it is left alone.