Part of Holtdroid · Documentation · All repositories
redroid (Android in a container) prepared for Holtdroid sessions, one image per Android version:
| Tag | redroid base | ARM64 translation from |
|---|---|---|
16 |
redroid/redroid:16.0.0-latest |
emulator image x86_64-36_r07 (libndk_translation + berberis) |
15 |
redroid/redroid:15.0.0-latest |
emulator image x86_64-35_r09 (libndk_translation + berberis) |
14 |
redroid/redroid:14.0.0-latest |
x86_64-34_r14 (libndk_translation) |
13 |
redroid/redroid:13.0.0-latest |
x86_64-33_r15 (libndk_translation; r16 and later ship no translation) |
12 |
redroid/redroid:12.0.0-latest |
x86_64-32_r08 (libndk_translation) |
11 |
redroid/redroid:11.0.0-latest |
x86_64-30_r16 (libndk_translation; the image has no ro.ndk_translation.version) |
Each is published as ghcr.io/holtdroid/holtdroid-redroid:<version> and
:<version>-<commit sha>; :main is the same as :15. The pins (base image
digest, system image zip and SHA-1) live in versions/<version>.env and are
passed to the Dockerfile as build args: make image ANDROID=14.
What the image changes on top of redroid:
-
adbd runs as root from boot (
service.adb.root=1in the vendor build.prop), so Holtdroid'sadb rootis a no-op instead of a restart. On Android 11 the restart crash-looped onCould not install *smartsocket* listener: Address already in use(the old listener's TIME_WAIT; that adbd sets noSO_REUSEADDR). -
Video encoders without DMA heaps. Stock redroid disables Codec2 unless the kernel has
/dev/dma_heap/systemor/dev/ion, so scrcpy finds no encoder. This image always enables Codec2, allocates linear blocks as gralloc BLOBs (debug.stagefright.c2-poolmask=851968) and uses the minigbm gralloc (cros), because the defaultgbmone crashes on BLOBs. Android 11 keepsgbm(GRALLOC=gbminversions/11.env): it has no Codec2 video encoders, onlyOMX.google.h264.encoderin a 32-bitmedia.codec, which can't lock screen buffers throughcros, so scrcpy got its codec header and then no frames. Encoders are software (c2.android.avc.encoder, HEVC, AV1). redroid's_64onlybases switch Codec2 on in their vendorbuild.prop(ro.boot.use_redroid_c2=1) and the ones with 32-bit ABIs don't, so the image adds it (CODEC2=offinversions/11.envkeeps Android 11 on OMX). The Android 12 base also lacksredroid.c2.rc, which runs the switch, so the image brings its own (vendor/etc/init/holtdroid-c2.rc). -
ARM64 translation.
libndk_translationfrom the Google APIs emulator system image of the same API level, so APKs with onlyarm64-v8alibraries run. Executing ARM binaries as separate processes (ro.enable.native.bridge.exec) is off: binfmt_misc is not namespaced and registering it would change the host. -
Google WebView as a provider. A static overlay (
build/rro/webview.xml) listscom.google.android.webviewinconfig_webview_packagesaftercom.android.webview, not available by default: the built-in WebView stays the default, and Holtdroid installs Google's WebView from its catalog and switches to it withcmd webviewupdate set-webview-implementation, which Android refuses for a package missing from the list. -
32-bit x86 apps. Every version is built on redroid's base with 32-bit ABIs (
zygote64_32), not the_64onlyone: Google Chrome's x86 builds run their browser in 32 bits (use32bitAbi) and abort on a 64-bit-only Android ("WebView cannot be started with a browser process type"). The emulator images carry no 32-bit ARM translation, soarmeabi-v7aandarmeabiare dropped from the vendor and ODM ABI lists, andro.dalvik.vm.isa.armwith them: on Android 12 to 15 an app with only 32-bit ARM libraries fails to install instead of crashing. Android 11's systembuild.propsets the ABI list itself and still names them. -
The built-in WebView is re-signed with Chromium's public debug key (
build/chromium-debug.keystore, fromchromium/src/build/android/chromium-debug.keystore, aliaschromiumdebugkey, passwordchromium). Chromium's snapshot builds ofSystemWebView.apk(com.android.webview) are signed with that key, so Holtdroid installs any of them as an update of the built-in WebView, andpm uninstall com.android.webviewgoes back to the factory one. redroid ships it signed with AOSP's test key, which no snapshot matches. The APK's contents are unchanged. -
Newer Vulkan drivers (
VULKAN=mesa, inversions/15.envandversions/16.env). redroid ships Mesa 24.0.8 built withandroid-strict, which on its API level advertises Vulkan 1.1 and hidesVK_KHR_pipeline_library,dynamic_rendering,synchronization2and more. Chromium 157 and later need them (ANGLE and Skia run on Vulkan, with no GL fallback) and abort with "GPU process isn't usable". Themesa-buildstage cross-compiles Mesa (MESA_VERSION) with the Android NDK and-Dandroid-strict=false: firstmesa_clcand the Intel shader precompiler for the build machine, then radv and anv forx86_64-linux-android30, with libdrm and expat linked in statically so the drivers need only Android's own libraries (the build fails otherwise). They replacevulkan.radeon.soandvulkan.intel.so; EGL and GLES stay redroid's. Verified on Intel UHD 630 (Chromium 158 and the built-in WebView 128 render WebGL at 60 fps on Vulkan 1.4); AMD (radv) is built the same way. -
No Bluetooth on Android 13 and 14 (
BLUETOOTH=offin theirversions/*.env). redroid's simulated Bluetooth HAL (android.hardware.bluetooth@1.1-service.sim, rootcanal) aborts as soon as the stack resets the controller: a CFI check inLinkLayerController::Reseton 13,Invalid addresson 14. Android enables Bluetooth at boot, socom.android.bluetoothand the HAL crash a few times each and Bluetooth ends up off and unusable anyway. The image drops theandroid.hardware.bluetoothfeature fromhandheld_core_hardware.xml, so system_server starts no Bluetooth service ("Bluetooth Hardware Not Present") and apps see a device without Bluetooth. The idle HAL stays. Android 11, 12 and 15 keep a working one. -
The mark-compact GC on Android 14 (
UFFD_GC=oninversions/14.env): the base image setsro.dalvik.vm.enable_uffd_gc=falsein the productbuild.prop, so ART uses the concurrent-copying GC, whose heap reservation is twicedalvik.vm.heapsize: 1 GiB in each of the two zygotes. Frida 17 looks for zygote'ssetArgV0slot by reading every unnamedrw-pmapping in full, heap first (kernels withoutCONFIG_ANON_VMA_NAMEleave ART's mappings unnamed), both zygotes at once, so frida-server peaked at 2.2 GiB and the session went over its 3 GiB limit. With the flag flipped totrue, as on 15 and 16, the heap reservation is 512 MiB and the peak about 1 GiB. -
A camera on Android 13 to 16 (
CAMERA=onin theirversions/*.env).build/camera/hal.cppis an AIDL camera provider (ICameraProvider/holtdroid/0, V1) built with the NDK against the image's ownlibbinder_ndk,libnativewindowandliblog, with the AIDL stubs generated from AOSP's frozen interfaces and AOSP'scamera_metadatacompiled in. It offers one LIMITED back camera (sensor at 90°) with YUV and JPEG streams up to 1920×1080, written by the CPU, so no GPU path is involved. On Android 15 and 16 it is lazy (CAMERA_LAZY=on): it starts once at boot so cameraserver finds it, exits a few seconds after the camera closes (ro.camera.enableLazyHal) and init starts it again on the next open. Android 13 and 14's cameraserver dereferences a null provider inAidlProviderInfo::startProviderInterfacewhen a lazy one isn't running (ondumpsys media.cameraand on opening the camera), so there the HAL stays up, idle, with no CPU use until an app opens the camera. Its frames come from Holtdroid's agent on the abstract socket@holtdroid-camera, which the Pod's containers share; Android's eBPF firewall refuses TCP from the cameraserver uid. Without the agent it shows color bars. Android 11 and 12 only take HIDL camera providers and have no camera. -
Android 16 on a mainline kernel (
KERNEL_COMPAT=on, only inversions/16.env). Android 16 expects an Android Common Kernel and stops booting on a mainline one in three places; thekernel-compatstages patch them in the image and change nothing on the node:- netbpfload accepts only the LTS branches it knows ("Android V+ only
supports LTS kernels"), so 6.18 is refused, the network BPF programs
aren't loaded and netd aborts. init.rc runs
holtdroid_netbpfloadinstead ofbpfloader: the same service, started withlibholtdroid_uname.sopreloaded, whoseuname()reports 6.12 when the kernel is newer. Only netbpfload sees it. - netd creates a BPF map when it starts. The node keeps
kernel.unprivileged_bpf_disabled=2, and the init wrapper hides the sysctl from Android, so creating one needsCAP_BPF, which netd'scapabilitiesgain. - libcutils uses memfd for ashmem only when the kernel answers ashmem's
ioctls on a memfd (an ACK patch) and falls back to
/dev/ashmem, gone from mainline since 5.18. system_server then aborts ("Failed to create ashmem") and the Codec2 encoders get no buffers.libholtdroid_ashmem.soanswers those ioctls on memfds the way ACK does (size fromfstat,ftruncate, write protection asF_SEAL_FUTURE_WRITE, pinning as a no-op) and passes every other ioctl on. Init exports it asLD_PRELOADby its absolute/system/lib64path, which the system and every APEX linker namespace may load; services from/vendorload the copy in/vendor/lib64by name instead, 32-bit system services the one in/system/lib, and init'sexecof vendor binaries goes throughenvfor the same reason. The library removesLD_PRELOADfrom the environment when it loads, so apps and child processes don't see it.
/holtdroid-redroid-initruns before Android's/init:- it moves itself into a child cgroup, unshares the cgroup namespace and
remounts
/sys/fs/cgroup. Without it Android's init can't create process groups and netd can't attach its BPF programs next to systemd's. In a container with its own cgroup namespace (an unprivileged Pod, or plaindocker run) it first mounts a fresh, writable cgroup2 there, since the runtime's is read-only, and makes/sys/devices/virtual/blockwritable under a read-only/sys(sysfs's superblock becomes writable,/sysitself stays a read-only mount), because APEX activation fails unless it can set its loop devices' queue settings; - it keeps Android off the node's sysctls. A privileged container writes
straight into the host's
/proc/sys, and Android's init.rc disableskernel.modprobeandkernel.sysrq, lowersvm.mmap_min_addr, retunes hung-task, scheduler and VM watermark settings, whilenetbpfloadsetskernel.unprivileged_bpf_disabledto 0 and reboots Android when that write fails. The build strips everywrite /proc/sys/...outside/proc/sys/netfrominit.rcandredroid.common.rc(thercstage, guarded by greps), and the wrapper forks a guard that waits for init's own procfs mount on/procand bind-mounts a plain file over each remaining target (unprivileged_bpf_disabled,modprobe,sysrq, the hung-task,sched_schedstats,perf_event_paranoidand watermark sysctls,mmap_min_addr), so the binaries' writes land in a file instead of the kernel. A read-only bind under the wrapper's/proc/syswould be hidden by that fresh procfs, and a read-only/proc/sys/kernelmakes init abort: it insists on raisingkptr_restrict(andmmap_rnd_bits), the two sysctls that stay real because raising them only hardens the node. Verified on a k3s node by diffingsysctl -aaround a boot: onlykernel.kptr_restrict1→2 remains.
- netbpfload accepts only the LTS branches it knows ("Android V+ only
supports LTS kernels"), so 6.18 is refused, the network BPF programs
aren't loaded and netd aborts. init.rc runs
-
Init services fit an unprivileged container: the
capsstage keeps in everycapabilitiesline of/system/etc/initand/vendor/etc/initonly the capabilities Holtdroid's unprivileged Android container has (build/caps/strip-caps.sh, the same list as Holtdroid's provider), since init refuses to start a service whose capabilities are outside the bounding set: logd asked forSYSLOGandAUDIT_CONTROL, the Wi-Fi HAL forSYS_MODULE, uprobestats forPERFMON. logd also raises those two itself unlessro.logd.kernelandro.logd.auditdare off, which the vendor properties now say, so logcat has no kernel or audit lines. -
A real Wi-Fi when the host has
mac80211_hwsimloaded withradios=0(Holtdroid decision #105). The Wi-Fi HAL,libwifi-hal.so,wpa_supplicant(and its init stanza) andmac80211_create_radioscome from the emulator vendor image of the same system image zip, with the libraries they need that redroid's vendor partition lacks.vendor/bin/holtdroid-wifi.shruns atpost-fs-data: it creates two radios in the container's network namespace, moves the second into a private router namespace and runs hostapd (openHoltdroidWifi) and dnsmasq there, NATing over a veth (wifiup) into the container's namespace. A second veth,eth1, is a wired uplink through the same router (Holtdroid uses it as mobile data,mobile0is its router end, down by default). hostapd and nft are Alpine's with their musl libraries in/vendor/holtdroid-net: Android's hostapd is a HAL on 14+, and its iptables needs legacy xtables, which mainline kernels may lack. A static overlay setsconfig_ethernet_iface_regextoeth1, so Android ignores the container'seth0(with Ethernet up it never joins Wi-Fi). Without the module there are no radios andmobile0stays up, so Android haseth1as plain Ethernet;vendor.holtdroid.wifi=1says the Wi-Fi is up and/data/vendor/wifi/router.pidholds a process in the router namespace. Radios are scoped to the network namespace that created them and go away with it. -
A plain home screen: a static overlay replaces the framework's
default_wallpaperwith Holtdroid's logo on a dark background, so the home and lock screens show it, and another one turns Launcher3'ssearch_container_workspaceinto an empty view, so the first home screen has no search bar. Holtdroid's agent clears the home screen and pins the app under test on it (Holtdroid decision #126).
The image is linux/amd64 only.
The host kernel needs binder (CONFIG_ANDROID_BINDERFS); for Wi-Fi,
modprobe mac80211_hwsim radios=0 on the host first. ashmem isn't
needed (androidboot.use_memfd=true, the default arguments). For GPU
rendering pass the render node:
docker run -d --privileged -p 5555:5555 ghcr.io/holtdroid/holtdroid-redroid:15 \
qemu=1 androidboot.hardware=redroid androidboot.use_memfd=true \
androidboot.redroid_gpu_mode=host androidboot.redroid_gpu_node=/dev/dri/renderD128 \
androidboot.redroid_width=720 androidboot.redroid_height=1600 \
androidboot.redroid_dpi=320 androidboot.redroid_fps=60In Kubernetes (containerd) the Pod also needs
securityContext.supplementalGroupsPolicy: Strict and the container
runAsUser: 0 and runAsGroup: 0: Android's /etc is an absolute symlink,
which containerd refuses to resolve when it adds supplemental groups from
/etc/group. Mount an emptyDir (or a volume) on /data. The container
runs either privileged: true or, as Holtdroid does (its decision #173),
with the capabilities in build/caps/strip-caps.sh only, seccomp
RuntimeDefault and devices handed in by Holtdroid's session-devices NRI
plugin: binder (any minor), /dev/loop-control and loop devices, FUSE,
TUN, the render node, and /dev/kmsg as /dev/null.
- No WebGPU in WebView: Chromium reports
Vulkan not supported with in process gpu(the same on the Android emulator). WebGL 1 and 2 run on the host GPU through Mesa. - No hardware video encoders; a constantly changing 720p screen costs about one CPU core to encode.
- Android 16 needs ashmem, which mainline kernels no longer have.
The build scripts are Apache-2.0. The image contains redroid (Apache-2.0,
AOSP) and libndk_translation from the Android emulator system image,
which is distributed under the Android Software Development Kit License
Agreement.
Caution
Fully AI-generated. Not a single line of this project was written or reviewed by a human: all of it was written by Claude through Claude Code. It's a Pandora's box, so use it at your own risk.

