DroidRunner
listening for jobs

android arm64 android-api-36 soc-mediatek-mt6899 android-npu npu-neuron

Your old phone is an ARM64 Linux machine with an NPU that happens to be sitting in a drawer. DroidRunner makes GitHub Actions treat it as one.

A single APK runs the official GitHub Actions runner on an Android device. No root. No Termux. No PC attached.

Get started → Download APK Source
$ uname -m
aarch64
$ droidrunner-device capabilities
device Xiaomi 2511FPC34G · android 16 (api 36)
soc mediatek mt6899
nnapi mtk-neuron_shim, mtk-dsp_shim, mtk-mdla_shim, nnapi-reference
thermal none · battery 93% charging
Connected to GitHub · Listening for Jobs
This is a self-hosted runner. It executes whatever a workflow contains, on your device — do not point it at repositories you do not trust.

why

GitHub's hosted ARM64 runners are virtual machines. They cannot tell you whether your model actually runs on a Qualcomm Hexagon, a Google Tensor TPU, or a MediaTek APU — because there is no NPU behind them, and no vendor driver stack either.

The devices that can answer that are the ones already in the drawer. DroidRunner's goal is a pool of real phones that CI can dispatch to by label, so "does this build work on ARM64" and "does this model run on that vendor's NPU" become the same kind of question: a job that runs on a push.

why a phone and not a single-board computer

The honest alternative to a phone is not a virtual machine — it is a Raspberry Pi on the shelf, or paying for hosted ARM64 minutes. Hosted linux-arm64 runners are free for public repositories and billed for private ones, so for a private repo the real comparison is a board you buy against a handset you already own.

Raspberry Pi 5a phone from the drawer
costboard, PSU, storage, casealready paid for
NPUnonethe reason this project exists
GPU for computenot in practicereached by the TFLite delegate
power cutit stops; a UPS costs extrathe battery is the UPS
state displayadd a screenit has one
Linuxordinary, with rootPRoot, no root, vendor power management
The battery is the part people underrate. A board loses a build to a flicker in the mains. A phone does not notice — and when a reboot does happen, DroidRunner records how long the device went unserved rather than leaving the gap invisible.

The board wins on being a normal computer. Root, predictable Linux, GPIO, and no vendor deciding what may run in the background. If your CI is a build and a test suite and nothing touches an accelerator, buy the board — it is the simpler machine, and this project is not trying to talk you out of it.

The phone wins the moment the question involves silicon. Everything further down this page — which driver actually ran a graph, which operators a vendor refuses, what an accelerator computed rather than merely who computed it — is unanswerable on a board with no NPU, and unanswerable on a VM for the same reason.

dashboard

DroidRunner's btop-style dashboard showing CPU, memory, disk, power and runner panels
cpu
Load history and per-core meters with live frequency.
mem / disk
RAM and app-private storage, with history.
pwr/net
Battery level and charging state, battery temperature, Android thermal status, network throughput.
runner
State, registered target, uptime, job counters and a live tail of the runner log — parsed straight from the official listener's output.
setup (⚙)
A separate screen: sign-in, target picker, registration, job policy. Once a device is registered, the runner starts on its own at launch.

how it works

GitHub Actions
      ↓  job dispatched by label
┌─ DroidRunner APK ────────────────────────────────┐
   Android controller  → Keystore (user token)
      ↓
   PRoot + Ubuntu ARM64 rootfs
      ↓
   Official Linux ARM64 Actions Runner
      ↓  loopback + per-job capability token
   Device Agent  → NNAPI / QNN / Neuron / ENN
└──────────────────────────────────────────────────┘

Shells, Git, Node, Python, Rust, Go and Gradle run inside PRoot like on any Linux box. Anything that needs an Android API or the NPU cannot be reached from inside that namespace, so it is delegated back to the app over a loopback API that only accepts a token minted for the running job.

proot itself ships inside the APK as a native library, because Android 10 and later refuse to exec() a binary out of app data storage. The downloaded runtime bundle is therefore data only — and its manifest is signed, so a device will not fetch a rootfs nobody vouched for.

features

Single APK

No companion app, no Termux, no scripts to paste.

No root

A Linux ARM64 environment on top of PRoot.

Official runner

GitHub's own linux-arm64 Actions runner, unmodified.

GitHub App login

Device-flow sign-in with a target picker. No PAT to create, no client secret in the APK.

Repo or org scope

Serve one repository, or every repository in an organization.

Automatic labels

Android API level, SoC and NPU hints become runner labels.

Self-protecting

Holds jobs while the device is hot, short on space, or on battery and below your threshold. Mains-only is a setting, off by default.

Crash recovery

Restarts the listener on its own, with backoff.

Ephemeral mode

Re-registers and wipes the work directory after every job.

Signed runtime

ECDSA-signed manifest, verified against keys compiled into the APK.

Keystore credentials

The GitHub token is encrypted by Android and never enters the Linux side.

Background standby

Foreground service and wake lock keep the runner alive.

a job on your phone

jobs:
  test:
    runs-on: [self-hosted, android, arm64]
    steps:
      - uses: actions/checkout@v4
      - run: uname -a
      - run: ./ci/test-arm64.sh

Or ask the silicon a question:

runs-on: [self-hosted, android, nnapi-accelerator]
steps:
  - run: droidrunner-device capabilities   # device info, thermal, drivers
  - run: droidrunner-device bench-all      # CONV_2D on every driver
DEVICE                       AVG_US     GFLOPS
mtk-dsp_shim                      - compilation_finish
mtk-mdla_shim                     - compilation_finish
mtk-neuron_shim              4148.9       4.55
nnapi-reference              1107.7      17.04

The two drivers that refused the job wanted a quantized model, not float32. That is precisely why labels are meant to come from probes rather than from parsing an SoC name.

labels

LabelMeaning
androidA physical Android device running DroidRunner
arm64ARM64 device
android-api-NAndroid API level
soc-*Detected SoC
android-npuThe SoC family is known to have an NPU. A hint, not a promise
nnapi-accelerator An accelerator answered the probe — the label to select on
npu-qnn A model has run on this phone's Hexagon — measured, not guessed
npu-tflite / npu-neuron / npu-enn LiteRT / MediaTek Neuron / Samsung ENN candidate, from the SoC name
nnapi, nnapi-accelerator and npu-qnn come from asking the device; the rest are candidates derived from the SoC name. Select on a measured one when a job needs acceleration, or it may quietly get a CPU. npu-qnn used to be a guess and is now earned: a Snapdragon carries it only after a model has been shown to run on its Hexagon.

what a phone answers that a VM cannot

EfficientNet-Lite0, same model and same harness on three phones. Every row says which processor actually ran it, not which one was asked for.

devicedriverint8float32
Tensor G4google-edgetpu0.65 ms16.7 ms
SM8650qnn-htp (Hexagon)1.22 ms2.78 ms
SM8650gpu (Adreno, TFLite delegate)4.95 ms
MT6899mtk-neuron_shim2.12 ms7.29 ms
MT6899mtk-mdla_shim2.69 msfell back to the CPU
Tensor G4NNAPI's own choice5.65 ms
SM8650nnapi-reference (CPU)113 ms39.8 ms
MT6899nnapi-reference (CPU)257 ms106 ms
The winner changes with the quantization. EdgeTPU takes int8; the Hexagon takes float32 by 2.6×. There is no "fast phone" — it depends on the model you ship.

Some cells are not the accelerator at all. MediaTek's MDLA refuses float32 outright and the CPU picks it up. An earlier version of this page would have printed that as a number; every result now reports who executed it, and a fallback says so.

Letting NNAPI choose costs most of the speedup — and it cannot reach a Hexagon at all, which is why that row needs the opt-in on the device.

which operators the silicon will take

A whole network cannot tell you. One refused node pushes its entire partition back to the CPU, so a fallback says a vendor's table is wrong somewhere without saying where. So: one model per operator per precision, 62 of them, run on every driver the phone exposes.

driverfloat32int8
qnn-htp (SM8650 Hexagon)31/3131/31
gpu (SM8650 Adreno, TFLite delegate)31/3131/31
mtk-neuron_shim (MT6899)28/3130/31
mtk-mdla_shim (MT6899)0/3130/31
mtk-dsp_shim (MT6899)10/3119/31
google-edgetpu (Tensor G4)0/3129/31
One SoC, three drivers, three different answers. A table with no per-driver axis is wrong for at least two of them whatever it says. The MDLA takes no float32 operator at all — which is unsupported, not emulated with a penalty, and those make a compiler emit different code.

Every model is also run with no accelerator at all. One that will not run on the CPU either is a defect of ours, and it is excluded and marked as excluded rather than filed as an operator some driver refuses.

Each phone's table is kept, and the next run is compared against it. A driver that stops taking an operator after a system update turns a build red. That check needs the same phone, kept — it is not available to anyone renting machines by the hour.

status

runner lifecycleworking
dashboard UIworking
GitHub App loginworking
admission controlworking
ephemeral + recoveryworking
signed runtimeworking
NNAPI probes + benchmarksworking
your own .tflite modelworking
your inputs, your outputs backworking
Qualcomm Hexagon (opt-in)working
result contract + actionworking
operator support matrixworking
conditions a number was measured underworking
GPU, on every phoneworking
two engines on one graphexperimental
MediaTek Neuron directblocked
fleet view, read-onlyworking

Everything marked working has run on a real device, not just in CI. The README carries the detail. blocked means every route was tried and each one needs a library that is absent or not loadable by an app — see issue #83.

before you start

Set up a device → Releases