If your CI runner or agent sandbox has no /dev/kvm, the Android emulator either refuses to start an x86_64 image or runs it slowly in software. Move the phone off the runner: create a remote Android phone over HTTP, install the APK from a URL, read its first screen and delete the phone. The runner needs only curl and jq.
Why the emulator stalls
The emulator’s VM acceleration uses a hypervisor, KVM on Linux. Without one, the emulator "must translate the machine code from the VM block by block", which the same page calls "quite slow". It also says you can’t run a VM-accelerated emulator "inside another VM, such as a VM hosted by VirtualBox, VMWare, or Docker". That rules out most containers and many cloud agent sandboxes. Sources reviewed on November 4, 2026.
Four ways out
| Option | What it needs | Trade-off |
|---|---|---|
| A runner with KVM | Bare metal or nested virtualization. android-emulator-runner enables KVM on ubuntu-latest with a udev rule | Full emulator and ADB; you maintain the boot, images and caching |
| Software emulation | -no-accel and software graphics | No new infrastructure; slow boots and timeouts |
| Redroid container | Host kernel modules binder_linux and ashmem_linux, a --privileged container | Fast, but you can’t load kernel modules on hosted runners |
| A remote phone over HTTP | An API key and network access | No ADB or emulator flags; the vendor’s device and limits |
Choose the first when you need ADB, logcat or instrumented tests. Choose a remote phone when the job is "install this build and check what a user sees".
Run it from any runner
This script is what we ran on November 4, 2026 against production, on a Linux host without /dev/kvm. It uses a small public APK from F-Droid. Set APK_URL to a direct download of your own build.
#!/usr/bin/env bash
# Install a public APK on a fresh cloud Android phone, open it, print the screen, delete the phone.
set -euo pipefail
MSB=${MOBILESANDBOX_URL:-https://mobilebox.dev}
AUTH="Authorization: Bearer ${MOBILESANDBOX_API_KEY:?set MOBILESANDBOX_API_KEY}"
APK_URL=${APK_URL:-https://f-droid.org/repo/org.billthefarmer.editor_198.apk}
api() { curl -sS --fail-with-body -H "$AUTH" -H 'Content-Type: application/json' "$@"; }
ID=$(api -X POST "$MSB/v1/phones?wait=ready&timeout=50" \
-d '{"name":"apk-check","idle_timeout":120,"max_duration":300}' | jq -r .id)
trap 'api -X DELETE "$MSB/v1/phones/$ID" && echo "deleted $ID"' EXIT
for _ in 1 2 3 4; do # boots can outlast one 50 s wait; poll the same id
STATUS=$(api "$MSB/v1/phones/$ID?wait=ready&timeout=50" | jq -r .status)
[ "$STATUS" = ready ] && break
[ "$STATUS" = failed ] && { echo "phone failed to boot" >&2; exit 1; }
done
[ "$STATUS" = ready ] || { echo "not ready: $STATUS" >&2; exit 1; }
INSTALLED=$(api -X POST "$MSB/v1/phones/$ID/apps" -d "{\"url\":\"$APK_URL\"}") || { echo "$INSTALLED" >&2; exit 1; }
echo "$INSTALLED"
PKG=$(jq -r .package <<<"$INSTALLED")
api -X POST "$MSB/v1/phones/$ID/actions" \
-d "{\"actions\":[{\"type\":\"open_app\",\"package\":\"$PKG\"},{\"type\":\"wait\",\"ms\":1500}]}" \
| jq -e -r 'if all(.results[]; .ok) then .screen.text else (.results | tostring | halt_error) end'The run took 25 seconds from create to delete and printed:
{"package":"org.billthefarmer.editor","version_code":198,"version_name":"1.98"}
app: org.billthefarmer.editor
[1] Text "Access all files" (540,923)
[2] Text "In order to have access to the Documents and other folders Editor must use the Files or All files access permission depending upon the version of Android. …" (540,1155)
[3] Button "CANCEL" (501,1413)
[4] Button "ACCESS ALL FILES" (790,1413)
deleted ph_rgcqe75zw24iThe first screen was a permission dialog, not the editor. That is a real result: a smoke test should expect it, or answer it like a user before checking the next screen.
What a pass means
The script fails the job on any HTTP error, a refused download or a failed action, and the trap deletes the phone in every case. A pass proves that the build installs on Android 14 arm64 and opens to the screen you printed. It does not prove Google Play services, a physical device, iOS or your whole user flow. For a Node version with a GitHub Actions job and a label check, see the CI smoke test. To install split bundles, see APKs and split bundles.
A ready phone costs $0.01 per minute, billed per second with a one-minute minimum per session. Boot time is free. Keys come from sign-up with GitHub, and new accounts start with $2 of credit.