CLAUDE.md
July 28, 2026 · View on GitHub
Guidance for Claude Code (and any AI assistant or new contributor) working in this repository. This is the deep map: read it before touching anything. Keep it accurate when you change the code.
What Is This
Podroid is an Android app that runs a real Alpine 3.24 Linux VM on stock Android 8+ (arm64) to provide rootless Podman / Docker / LXC containers and an in-app X11 desktop - no root, no custom recovery.
- Two interchangeable VM backends behind one interface (
VmEngine):- QEMU (TCG) - software emulation, the default, needs no special permission.
- AVF (pKVM) - hardware-accelerated via the Android Virtualization Framework on Pixel-class devices, after a one-time
pm grant.
- The guest is a standard Alpine root with OpenRC as PID 1. System services live in
/etc/init.d/podroid-*on a read-only squashfs; a persistent ext4 overlay captures user changes (apk add,rc-update add). - An embedded Termux-based terminal, an X11/VNC viewer (Xvnc + PulseAudio), and a guest-to-Android bridge (
podroid-notify/podroid-forward) round it out.
Key Facts
| Package | com.excp.podroid (debug: com.excp.podroid.debug) |
| Version | versionName / versionCode in app/build.gradle.kts |
| Min / target SDK | 26 (Android 8) / 36 |
| Architecture | arm64 (aarch64) only |
| Guest | Alpine 3.24 squashfs + persistent ext4 overlay, OpenRC PID 1 |
| Kernel | custom Linux, version pinned by podroidKernelVersion in gradle.properties |
| QEMU | version pinned by podroidQemuVersion in gradle.properties |
| UI | Jetpack Compose + Material 3, single Activity |
| DI | Hilt (constructor injection) |
| Async | Coroutines + StateFlow |
| Persistence | Jetpack DataStore (no database; Room is declared but unused) |
| Terminal | vendored Termux fork as local Gradle modules (terminal-view, terminal-emulator) |
| Languages | English + 中文 (Chinese); see data/repository/LanguageManager.kt + res/values-zh/ |
Build Commands
All native/VM components are coordinated by build-all.sh (Docker-cached):
./build-all.sh kernel # custom kernel only (podroid_kernel.config)
./build-all.sh initramfs # kernel + minimal initramfs
./build-all.sh rootfs # Alpine squashfs -> app/src/main/assets/alpine-rootfs.squashfs
./build-all.sh qemu # QEMU + native helpers via Docker (slow first time)
./build-all.sh termux # terminal-emulator JNI for 16KB pages
./build-all.sh apk # Android APK via Gradle
./build-all.sh all # everything
./build-all.sh deploy # all + install + launch
./build-all.sh test # boot validation: installs, polls console.log for "Ready!"
APK only: ./gradlew assembleDebug / ./gradlew installDebug.
Unit tests: ./gradlew :app:testDebugUnitTest (use the :app: form, not the bare task).
Monitor VM boot:
adb logcat -s PodroidQemu
adb shell run-as com.excp.podroid.debug cat files/console.log # debug build
Release builds are signed via signingConfigs.release (keystore podroid-release.jks), fed by the PODROID_RELEASE_STORE_FILE / _PASSWORD / _KEY_ALIAS / _KEY_PASSWORD Gradle properties. Release applicationId = com.excp.podroid; debug gets applicationIdSuffix = ".debug" + versionNameSuffix = "-debug". Any code comparing the local version against an upstream release tag must strip an optional -debug suffix (see UpdateRepository.checkForUpdate).
Native binaries require 16KB page alignment (-Wl,-z,max-page-size=16384) - mandatory on Android 13+; verified by an ELF parser in build-all.sh.
Architecture
Engine abstraction (the most important thing to understand)
Everything VM-related goes through engine/VmEngine.kt, a single interface implemented by two backends and one router:
QemuEngine.kt- QEMU/TCG. Software emulation, SLIRP user-mode networking, control via QMP and virtio-console Unix sockets. No special permission.engine/avf/AvfEngine.kt- AVF/pKVM. Uses the Android Virtualization Framework (reached by reflection inAvfReflect.kt); networking and control ride vsock. Hardware-accelerated; needsMANAGE_VIRTUAL_MACHINE+USE_CUSTOM_VIRTUAL_MACHINEgranted viapm grant.EngineHolder.kt- the single Hilt binding forVmEngine. Picks the concrete engine at startup based onEngineSelection(Auto / AVF / QEMU), watches Settings for backend changes, and routes imperative calls + flow access to whichever is current. Also owns the live diff that appliesPortForwardRepositorychanges to the running VM.
When you touch VM behavior, decide whether it is backend-neutral (put it behind VmEngine / EngineHolder) or backend-specific (inside QemuEngine or AvfEngine).
Data flow
Terminal UI (Compose)
| TerminalSession (vendored Termux JNI)
libpodroid-bridge.so <-> terminal.sock + ctrl.sock <-> VM <-> hvc0 + hvc1
boot monitor <-> serial.sock (QEMU) / console stream (AVF) -> console.log + boot-stage detector
QmpClient <-> qmp.sock (QEMU only) -> runtime port forwards (netdev_add/remove)
HostRequestServer <-> host.sock/hvc2 (QEMU) | vsock:9101 (AVF) <- podroid-notify / podroid-forward
QEMU exposes these Unix sockets under context.filesDir, each with one role:
terminal.sock↔ virtio-console/dev/hvc0- primary terminal I/O. getty runs on hvc0;libpodroid-bridge.sorelays it to a Termux PTY.ctrl.sock↔ virtio-console/dev/hvc1- resize channel. The bridge debounces SIGWINCH bursts and writes oneRESIZE rows cols\n; a guest resize daemonsttys hvc0.serial.sock↔ PL011/dev/ttyAMA0- boot-log sink only. The boot monitor streams kernel + init output intoconsole.logand the boot-stage detector.qmp.sock- QEMU Machine Protocol for runtime port forwarding and USB hot-plug.host.sock↔ virtio-console/dev/hvc2- the guest→Android host bridge (see below).
On AVF there is no QMP and no PL011: the console is captured via ConsoleFanout.kt, control/resize go over VsockControlChannel.kt, port forwards over VsockPortForwarder.kt, and the host bridge over vsock port 9101.
Boot pipeline
- The engine launches the VM and starts the boot monitor (
QemuBootMonitor.ktfor QEMU; the console stream for AVF).BootStageDetector.ktscans the rolling console buffer (last ~1KB, not per-read()chunk - fast devices split markers likeReady!across reads). init-podroid(initramfs, ~45 lines): mounts the persistent ext4 (/dev/vda→ upper) and the read-only squashfs (/dev/vdb→ lower), stacks an overlayfs, moves the mounts into the new root, andswitch_roots into/sbin/init(busybox).- Busybox
/sbin/initreads/etc/inittaband starts OpenRC (runlevels are pre-symlinked at build time - chroot-into-aarch64 doesn't work on an x86_64 builder). - OpenRC services on the squashfs do all system bringup:
podroid-bootstrap- kernel modules, cgroup v2, devpts/shm/mqueue, sysctl, ZRAM swap,mount --make-rshared /, container dirs.podroid-network- eth0 up, addressing, default route,/etc/resolv.conf.podroid-resize- readsRESIZE rows colsfrom/dev/hvc1,sttys/dev/hvc0.podroid-hostd- the host-bridge daemon (both backends).podroid-vsock- the AVF control/forward agent (AVF boots only).dropbear- SSH, when enabled.podroid-ready- emitsStarting SSH.../Almost ready.../Ready!, the markersBootStageDetectormatches;Ready!flips state toRunningand auto-starts the terminal bridge.
Why switch_root, not chroot: an earlier version chrooted into the overlay, which broke podman exec -it - setns(MNT) in crun exec resets fs->root, so the exec'd process saw raw kernel paths (/mnt/overlay/proc) instead of /proc. switch_root reorganizes the kernel mount tree itself, so namespace forks see a clean /.
Guest → Android host bridge
Lets guest processes call back to Android. Guest side: podroid-hostd (multi-call C binary, build-rootfs/host-bridge/podroid-hostd.c) owns a guest-local AF_UNIX socket /run/podroid-host.sock; argv[0] dispatch also exposes it as podroid-notify and podroid-forward (symlinks). The daemon relays one request line / one response line to Android over a backend transport: /dev/hvc2 on QEMU, AF_VSOCK:9101 on AVF (chosen by the podroid.backend=avf cmdline marker). Android side: engine/hostbridge/ - HostRequestServer reads requests over a HostTransport (QemuHostTransport via LocalSocket to host.sock, or AvfHostTransport via connectVsock), HostRequestDispatcher parses them, and routes to NotificationPoster (posts via NotificationManagerCompat) or PortForwardRepository (rules persist and EngineHolder applies them live). PodroidService starts/stops the server over the VM lifecycle. Free-text fields are base64 (HostProtocol.kt) so UTF-8 survives.
Gotcha:
/dev/hvc2is a virtio-console TTY that defaults to echo on. The daemon mustcfmakeraw()it, or the TTY echoes Android's responses back and the protocol desyncs after the first request. AVF (a raw vsock socket) is unaffected.
Downloads sharing over 9p (engine/avf/AvfDownloadsShare.kt + engine/avf/ninep/)
AVF only. Serves the real Android Downloads directory into the guest by running an in-process 9p2000.L server (Ninep2000LServer.kt, ~886 LoC; wire format in NinepCodec.kt) over vsock. This is why the guest can read and write user files without the app holding broad storage permissions, and it is how podroid-backup archives land in Downloads/Podroid/backups for ContainerBackupRepository to list.
There is no QEMU equivalent, so any feature built on the share is backend-asymmetric by construction and must degrade cleanly on QEMU.
USB passthrough (engine/usb/UsbPassthroughManager.kt)
QEMU backend only. An unprivileged app can't open /dev/bus/usb, so it takes the already-open fd from UsbManager/UsbDeviceConnection and streams it to QEMU over qmp.sock as SCM_RIGHTS (add-fd), then hot-plugs with device_add usb-host,hostdevice=/dev/fdset/N. A code-registered BroadcastReceiver (no manifest device_filter.xml) is live only while the VM is Running. Gated by the usb_passthrough_enabled setting, which also makes buildCommand() emit -device qemu-xhci. Needs a libusb-enabled QEMU build.
X11 viewer (x11/ + ui/screens/x11/)
In-app viewer that talks RFB to Xvnc in the guest, with touch→mouse, soft-keyboard, external mouse/keyboard, fullscreen, rotation lock, resolution presets, and PCM audio over PulseAudio loopback. Backed by always-on implicit VNC/audio port forwards.
Internationalization
data/repository/LanguageManager.kt + res/values/ / res/values-zh/. MainActivity.attachBaseContext wraps the context for the saved locale (read synchronously from a cache file before Hilt is available); changing the language in Settings persists it and recreates the Activity. Don't reintroduce hardcoded user-facing strings - use stringResource and keep values / values-zh in sync.
Navigation
Single-activity Compose app: ui/navigation/NavGraph.kt routes setup → home → terminal / settings / x11. TerminalViewModel is scoped outside the NavHost so the session survives screen changes. The setup wizard shows until setup is marked complete in DataStore.
Project Structure
/
├── app/ # Android module
│ └── src/main/
│ ├── java/com/excp/podroid/
│ │ ├── MainActivity.kt # single Activity, locale wrap, WindowSizeClass
│ │ ├── PodroidApplication.kt # Hilt app, asset extraction on first run
│ │ ├── engine/
│ │ │ ├── VmEngine.kt # backend interface
│ │ │ ├── EngineHolder.kt # Hilt binding; routes to current engine; port-forward diff
│ │ │ ├── EngineModule.kt, EngineSelection.kt
│ │ │ ├── QemuEngine.kt # QEMU/TCG backend, buildCommand()
│ │ │ ├── QemuBootMonitor.kt, BootStageDetector.kt
│ │ │ ├── QmpClient.kt # QMP: port forwards + USB add-fd
│ │ │ ├── ResizeNotifyingSession.kt, VmState.kt
│ │ │ ├── avf/ # AVF/pKVM backend (engine, reflection, vsock, console)
│ │ │ │ ├── AvfDownloadsShare.kt # live Downloads share, AVF only (see below)
│ │ │ │ ├── AvfDiagnostics.kt # AVF diagnostic + smoke-test entry point
│ │ │ │ └── ninep/ # in-process 9p2000.L server (Ninep2000LServer, NinepCodec)
│ │ │ ├── hostbridge/ # guest->Android bridge (transport, server, dispatcher, notify)
│ │ │ │ └── HeadlessModeManager.kt # single source of truth for server (headless) mode
│ │ │ └── usb/ # UsbPassthroughManager
│ │ ├── service/PodroidService.kt # foreground service; owns VM lifecycle, wakelock, notification
│ │ ├── data/repository/ # Settings, PortForward, Update, Language, ContainerBackup, ContainerStats (all DataStore)
│ │ ├── di/ # Hilt module
│ │ ├── util/ # NetworkUtils, ShellQuote, HostMetrics + VmLoadSampler (Status screen), DeviceResourcePolicy (load-balance sizing)
│ │ ├── x11/ # X11/VNC viewer engine
│ │ └── ui/ # Compose: navigation, theme, screens/{setup,home,terminal,settings,x11,status,backup}, components
│ ├── res/values, res/values-zh/ # strings (EN + Chinese)
│ ├── assets/ # vmlinuz-virt, initrd.img, alpine-rootfs.squashfs (all gitignored, built locally),
│ │ # qemu/, colors/ (122), fonts/ (13), ui-fonts/
│ └── jniLibs/arm64-v8a/ # native executables (see below)
├── terminal-view/, terminal-emulator/ # vendored Termux fork (local Gradle modules)
├── init-podroid # initramfs bootstrap: overlay + switch_root
├── podroid-bridge.c # PTY <-> virtio-console relay -> libpodroid-bridge.so
├── podroid-launcher.c # exec wrapper that ties QEMU's lifetime to the app -> libpodroid-launcher.so
├── Dockerfile # kernel + initramfs + QEMU build
├── build-tools/ # static assets used during Docker builds
│ └── cross-android-aarch64.ini # Meson cross-compilation config for aarch64-android26
├── build-rootfs/
│ ├── Dockerfile.rootfs, build-rootfs.sh
│ ├── vsock-agent/ # podroid-vsock-agent.c (AVF control/forward agent)
│ ├── host-bridge/ # podroid-hostd.c (guest->Android bridge daemon + CLIs)
│ └── files/etc/ # OpenRC services + configs baked into the squashfs
├── build-all.sh, gradle.properties, build.gradle.kts, settings.gradle.kts
└── README.md, CONTRIBUTING.md, CREDITS.md
Native Binaries (app/src/main/jniLibs/arm64-v8a/)
ELF executables renamed .so for APK packaging; run via ProcessBuilder / TerminalSession, not loaded as JNI libraries. All 16KB-aligned.
| File | What it is |
|---|---|
libqemu-system-aarch64.so | QEMU TCG emulator |
libpodroid-bridge.so | PTY ↔ virtio-console relay (from podroid-bridge.c) |
libpodroid-launcher.so | exec wrapper for QEMU process lifetime (from podroid-launcher.c) |
libslirp.so | SLIRP user-mode networking (soname patched .so.0→.so) |
The terminal emulator JNI is built from the vendored terminal-emulator module (rebuilt for 16KB pages), not shipped as a prebuilt here.
QEMU command construction (QemuEngine.buildCommand())
-serial unix:serial.sock(boot log) + avirtio-serial-pcibus carrying three virtconsoles:terminal.sock(hvc0),ctrl.sock(hvc1),host.sock(hvc2, the host bridge - order matters, the guest expects host bridge on hvc2).-qmp unix:qmp.sockfor runtime port forwards + USB.- RAM, CPU count, and user-editable extras (
-cpu,-accel, RNG, etc.) fromSettingsRepository. - Two virtio block devices:
/dev/vda←storage.img(writable ext4 overlay upper, resized on first boot),/dev/vdb←alpine-rootfs.squashfs(read-only zstd lower). - SLIRP networking;
-device qemu-xhciwhen USB passthrough is enabled.
Build pipelines
Dockerfile- custom Linux kernel (arm64 defconfig +podroid_kernel.configmodules +forced_builtin.configforcing overlayfs / netfilter / bridge / veth / tun / FUSE / IPv6 etc. to=y) and QEMU cross-compiled against the NDK. A build-time check greps the resolved.configand fails the build if any critical option isn't=y(guards against silent Kconfig demotion from unmet tristate deps). QEMU needs--enable-libusb(for passthrough) and a few Android/Bionic patches;build-all.sh qemumay needdocker build --network=host.build-rootfs/Dockerfile.rootfs- fetches the Alpine minirootfs, runsbuild-rootfs.sh(apk-installs alpine-base + openrc + podman + crun + fuse-overlayfs + docker + lxc + dropbear + iptables/nftables + bridge-utils, sets root passwordpodroid, seals file caps onnewuidmap/newgidmap, copies the OpenRC services and the cross-compiledpodroid-vsock-agent+podroid-hostd, wires runlevels via direct symlinks), thenmksquashfs -comp zstd(kernel shipsCONFIG_SQUASHFS_ZSTD=y).
Performance tuning (TCG path; KVM is impossible without root)
In QemuEngine.buildCommand(): tcg,thread=multi, larger tb-size for ≥2GB RAM, dedicated iothread on virtio-blk-pci, -cpu max,pauth-impdef=on. More vCPUs are not better here: on an 8-core phone, 8 measured slower than 4 on every metric. Guest cmdline: mitigations=off (safe inside TCG), per-device mq-deadline. init-podroid/bootstrap: ZRAM lz4 swap at half RAM. CONFIG_EXT4_FS_SECURITY=y + CONFIG_SQUASHFS_XATTR=y keep security.capability xattrs across the overlay (rootless podman's newuidmap needs them). What won't work without root: io_uring (seccomp), CPU affinity, KSM, TAP networking, host hugepages.
Quirks & gotchas
CONFIG_DEVTMPFS_MOUNT=ypre-mounts/devbefore/initruns.init-podroidmust usemount ... || truefor the devtmpfs line — the kernel's pre-populated/devis sufficient and a secondmount(2)returns EBUSY. Also:size=is not valid for devtmpfs in kernels where it is ramfs-backed (causes EINVAL). Either failure exits util-linux withMNT_EX_FAIL=32;set -epropagatesexit(32), encoded asexitcode=0x00002000("Attempted to kill init!"). Always keep the|| true.- util-linux
mountstops option parsing at the first non-option argument whenPOSIXLY_CORRECTis set. Put-oflags before device and mountpoint (mount -t proc -o noexec,nosuid,nodev proc /proc), not after. Options placed after the mountpoint are silently dropped, causingmount(2)to be called withflags=0and returning EPERM — which util-linux prints as "must be superuser to use mount" even for root. Dockerfileheredoc gotcha. Docker BuildKit's parser treats[section]lines insideRUN ... << 'EOF'heredocs as unknown Dockerfile instructions and aborts the parse. Instead of using shell heredocs inRUNfor multi-line config files, useCOPY build-tools/<file>from the build context. The Meson cross-compilation config lives inbuild-tools/cross-android-aarch64.inifor this reason.- Backend asymmetry is the #1 source of bugs. QEMU = SLIRP + QMP + virtio-console (TTY); AVF = DHCP + vsock (raw sockets). Test both. The host bridge's
cfmakerawon hvc2 (above) is a concrete example. - Boot detection scans a rolling buffer, not per-read chunks (fast devices split markers).
- Bridge stderr is silenced (
dup2(/dev/null, STDERR)): it runs as aTerminalSessionsubprocess whose stderr IS the PTY. Never addfprintf(stderr, ...)topodroid-bridge.c. forceUpdateSizeFromViewmust not multiply by scaledDensity -TerminalViewpasses the raw int textSize toPaint; mismatched math renders TUI apps in the wrong grid.- SLIRP has no ICMP (
pingfails in the guest) and its DNS forwarder is unreliable on Android (resolv.conf uses public DNS). - Privileged ports: the app can't bind host ports < 1024 (no
CAP_NET_BIND_SERVICE), so SSH is on 9922, not 22. Forward to a high host port. - Reflection into the vendored Termux fork (
mTermSession,mEmulator,mCurrentDecSetFlags) is kept byapp/proguard-rules.pro. - Persistence is sacred: DataStore keys and
filesDirpaths survive every release; renames need migration. Updates must install in place (sameapplicationId+ signing key, monotonicversionCode).
VM migration / upgrades (how the guest updates without a reset)
The guest system layer updates across app versions with no VM reset and no data loss, on both backends. The machinery:
- Plain overlay (never re-add metacopy/index/redirect).
init-podroidmounts the rootfs overlay aslowerdir=/mnt/lower,upperdir=/mnt/persist/upper,workdir=/mnt/persist/workonly. Plain overlayfs tolerates a swapped lower, so a new squashfs (re-extracted byPodroidApplicationon every update) goes live on the next boot while the persistent upper is preserved. Do not re-addmetacopy=on/index=on/redirect_dir=on- they bind the upper to a specific lower and reintroduce the corruption-on-update bug (the whole reason resets used to be needed). - Version anchor. The squashfs ships
/etc/podroid/system-version(baked fromversionCodebybuild-all.sh->Dockerfile.rootfsARG ->build-rootfs.sh). The last-applied version persists at/mnt/persist/.podroid/applied-version. - One-time legacy normalization.
init-podroidrunspodroid-overlay-normalize(shipped in the squashfs, invoked via/mnt/lower, before the overlay is stacked) once per device (guarded by/mnt/persist/.podroid/normalized) to strip pre-existingmetacopy/redirect/indexstate from uppers created by the old metacopy overlay. No-op on fresh/normalized uppers. - Imperative hooks.
podroid-migrate(OpenRC, runsbefore podroid-bootstrap) executes/etc/podroid/migrations/<v>.shforapplied < v <= system-versionin order, then advancesapplied-versionatomically. To ship a fixup in a new release (e.g. enable a newly-added service): addbuild-rootfs/files/etc/podroid/migrations/<versionCode>.sh, idempotent, install it inbuild-rootfs.sh. Pure file additions/changes need NO script - the overlay union surfaces them. - Reliability: the marker advances only after migration completes (crash -> idempotent re-run); nothing auto-wipes
/mnt/persist;init-podroidkeeps itsFATAL -> exec shrecovery shell.
Common tasks
- Kotlin/UI change →
./gradlew assembleDebug && ./gradlew installDebug. - OpenRC service / package list / guest CLI change → edit under
build-rootfs/,./build-all.sh rootfs, rebuild APK. New/changed system files go live on the next VM boot with no reset (the plain-overlay union surfaces them; see "VM migration / upgrades"). The exception is a path the user already modified - it lives in the persistent upper and keeps the user's version until removed, so use a/etc/podroid/migrations/<v>.shscript for that case. init-podroidchange →./build-all.sh initramfs, rebuild APK.- New boot stage → emit the marker in the right OpenRC service + match it in
BootStageDetector. - New setting → add a DataStore key + Flow in
SettingsRepository, UI inSettingsScreen, setter inSettingsViewModel, and plumb intoVmConfig/buildCommand()if it affects the VM. - New user-facing string → add to
res/valuesANDres/values-zh, usestringResource. - Verify before "done": compile, run
:app:testDebugUnitTest, and for behavior changes install and walk the flow on a device (unit tests don't catch DI/graph or backend-specific issues).
Proving a change on a device
Unit tests cover pure logic only. Anything touching VM behavior, the engine boundary, or a user-visible flow is unproven until it runs on hardware, and the two backends need two different devices:
| Backend | Device requirement | Extra setup |
|---|---|---|
| QEMU | any arm64 Android 8+ device | none; this is the default path |
| AVF | a device reporting android.software.virtualization_framework (Pixel-class) | pm grant both MANAGE_VIRTUAL_MACHINE and USE_CUSTOM_VIRTUAL_MACHINE, then force-stop the app |
Check AVF capability before assuming a device can exercise it:
adb shell pm list features | grep virtualization
A device without that feature can never run AvfEngine, no matter the EngineSelection setting. EngineHolder.pick() re-evaluates only at process start, so a fresh pm grant does not take effect until the app is force-stopped and relaunched.
A change that touches both backends is unproven until it has been installed and walked on both. Backend asymmetry is the #1 source of bugs (see Quirks & gotchas), and AvfEngine.kt is the most-churned file in the repository, so a QEMU-only test is the easiest way to ship a regression.
Reading the VM console depends on build type: run-as works on debug, but release builds are not run-as-able, so use su or Settings → Diagnostics → Export Log there.
adb shell run-as com.excp.podroid.debug cat files/console.log # debug only