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 

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) 

Install

pip install git+https://github.com/foxphone/adbfleet 

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} ] } 

You can edit it by hand or from the CLI:

adbfleet name R58M41ABCDE lab-s21 adbfleet tag add lab-s21 qa android13 

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 

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" 

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" 

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 

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.