DEV Community

FoxPhone
FoxPhone

Posted on

One adb command, twenty Android devices: a tiny fleet helper for USB phones, emulators and cloud phones

If you test Android apps on more than two or three devices, you have probably written this loop at least once:

for s in $(adb devices | awk 'NR>1 && $2=="device" {print $1}'); do
  adb -s "$s" install -r app-release.apk
done
Enter fullscreen mode Exit fullscreen mode

It works, until it doesn't. It runs one device at a time, the output from different devices runs together, serials like R58M41ABCDE or 203.0.113.10:5555 are hard to recognise, and when one install fails halfway down the list the loop still exits 0.

We hit all of these while running QA on a mix of desk phones, emulators and network-attached Android devices, so we wrote a small open-source helper called adbfleet. This post explains how it works and what we learned along the way.

What it does

adbfleet is a thin wrapper around the normal adb binary. It does not replace adb or talk to any vendor API. It just:

  • runs the same adb command on many devices in parallel (8 at a time by default, -j to change it),
  • prints each result with a friendly device name instead of a raw serial,
  • keeps a small JSON inventory of names, tags and network endpoints,
  • returns a non-zero exit code if any device failed, so it is safe to use in CI.

It is pure Python 3.8+ standard library, with no third-party dependencies.

$ adbfleet shell -t qa -- getprop ro.build.version.release
[lab-s21]   13
[emu-api34] 14
[cloud-01]  12
-- 3/3 ok (0.4s device time)
Enter fullscreen mode Exit fullscreen mode

Install

pip install git+https://github.com/foxphone/adbfleet
Enter fullscreen mode Exit fullscreen mode

You still need Android platform-tools. adbfleet looks for adb in $ADBFLEET_ADB, then $ANDROID_HOME/platform-tools, then your PATH.

Naming and tagging devices

Raw serials are the main reason multi-device logs are painful to read. The inventory is a plain JSON file (./fleet.json or ~/.config/adbfleet/fleet.json):

{
  "devices": [
    {"serial": "R58M41ABCDE", "name": "lab-s21", "tags": ["qa", "android13"]},
    {"serial": "emulator-5554", "name": "emu-api34", "tags": ["qa", "smoke"]},
    {"serial": "203.0.113.10:5555", "name": "cloud-01", "tags": ["cloud", "us"], "connect": true}
  ]
}
Enter fullscreen mode Exit fullscreen mode

You can edit it by hand or from the CLI:

adbfleet name R58M41ABCDE lab-s21
adbfleet tag add lab-s21 qa android13
Enter fullscreen mode Exit fullscreen mode

Then pick devices with selectors you can combine:

adbfleet install -t qa app.apk          # any device tagged qa
adbfleet health --all-tags cloud,eu     # devices with both tags
adbfleet reboot -e                      # emulators only (-d USB, -r network)
adbfleet shell -x slow -- uptime        # everything except devices tagged slow
adbfleet reboot -t qa --dry-run         # show what would run, change nothing
Enter fullscreen mode Exit fullscreen mode

A real install check

One detail that bit us: adb install can print Failure [INSTALL_FAILED_...] and still exit 0 on some platform-tools versions. adbfleet looks at the output too, so that device is marked as failed and the whole command exits 1. That is the difference between a green CI job that shipped nothing and a red one you can actually fix.

adbfleet install -t qa -R -g build/app-release.apk || echo "at least one device failed"
Enter fullscreen mode Exit fullscreen mode

Health checks you can put in cron

health reads model, Android version, battery, temperature, free space on /data, uptime and screen size in one round trip per device:

# fail the CI step if any test device has less than 500 MB free
adbfleet health -t ci --min-free-mb 500 -q

# daily snapshot of the whole fleet
adbfleet health --csv > "fleet-$(date +%F).csv"
Enter fullscreen mode Exit fullscreen mode

Network devices and cloud phones

More and more test devices are not on a USB cable at all. Wi-Fi debugging, ADB-over-TCP device racks, emulators on another machine and hosted Android "cloud phones" all show up as a host:port endpoint you attach with adb connect.

Disclosure: I work on FoxPhone, a cloud phone service, and that is why network devices got first-class support. But nothing in adbfleet is specific to us: any endpoint that adb connect accepts works the same way.

Mark those entries with "connect": true and a tag, and a session looks like this:

adbfleet connect -t cloud                 # attach every cloud endpoint, with retries
adbfleet health -t cloud --min-battery 15
adbfleet install -t cloud -R build/app-release.apk
adbfleet launch -t cloud com.example.app
adbfleet screenshot -t cloud --out run-42/
adbfleet disconnect
Enter fullscreen mode Exit fullscreen mode

pull saves files into DEST/<device-name>/ so results from different devices never overwrite each other, and screenshot writes screenshots/<name>-<timestamp>.png per device.

Limits worth knowing

  • No interactive adb shell and no never-ending streams like plain logcat; every job must finish on its own (logcat -d works).
  • Output is collected per device and printed when that device finishes, so huge outputs live in memory.
  • launch uses monkey to open the launcher activity; for a specific activity use shell -- am start -n pkg/.Activity.

Try it

The code, tests (which use a fake adb, so no devices needed) and examples are on GitHub: github.com/foxphone/adbfleet. Issues and pull requests are welcome, especially reports from people running larger or stranger device setups than ours.

Top comments (1)

Collapse
 
autenai profile image
Auten •

The "exit 0 on INSTALL_FAILED" check is the kind of thing you only learn after a green CI run that shipped nothing. Nice catch.

One thing that may bite the inventory on desk phones: with Android 11+ wireless debugging, the connect port is random and changes whenever wireless debugging is toggled or the phone reboots, so a fixed host:port entry goes stale overnight. Classic adb tcpip 5555 and cloud endpoints keep their port, so it's mostly the Wi-Fi desk phones. adb mdns services lists the current _adb-tls-connect endpoint per device, so a connect step could resolve by device name through mDNS and fall back to the stored port.

Do you key cloud entries on the endpoint only, or also check ro.serialno after connecting? When a cloud host recycles a port to a different device, a health run would otherwise report the wrong phone under the friendly name.