Android RPA with adb and uiautomator2: a practical setup
Twelve steps, three phones, one always-on PC, no API. That is the whole job. An Android app you own has a routine flow that only exists as taps on a screen, and someone does it by hand every morning: log in with a test account, open a ticket, attach a photo, submit, check the status screen, save a screenshot. Call it fifteen minutes per phone when nobody interrupts.
This is a reference build. There is no client behind it, I have no fleet benchmark to quote, and I am not going to invent one. The target is a flow that runs unattended every night on all three phones, and every failed step leaves a screenshot, a UI dump and a logcat tail behind, so the morning fix is a short edit instead of a re-run and a guess. The cost is the phones, a powered USB hub and about two days of scripting. The commands come from the adb documentation and the uiautomator2 README. Figures later are arithmetic on stated assumptions, so swap in your own, and anything version-sensitive is dated as of September 2026.
One boundary first. Automate only apps and devices you own or are authorised to test. Many third-party apps forbid automation in their terms of service, and I will not cover getting around an app’s security checks or anti-automation rules. This is not legal advice, so read the terms of whatever you touch.
the setup
The stack, as of September 2026:
- host: any always-on PC. Windows works, Linux is easier. Python 3.12 in a venv.
- adb: Android SDK Platform-Tools from Google, one copy on PATH. I log the output of
adb versionwith every run. - uiautomator2: the 3.x line from PyPI, pinned in requirements.txt (check the current release yourself). MIT licensed, and it drives Google’s UI Automator framework through a small server it starts on the phone. Older tutorials open with an init step for a device daemon, so follow the README for the version you install.
- phones: two Android 14 handsets (API 34) and one Android 12 handset (API 31), all mine, on a powered USB hub, with no personal accounts on them.
- scheduling: Windows Task Scheduler, one task per phone, 02:00 start. Cron does the same on Linux.
- output: one log per phone per night, plus a folder per failed step.
Each phone gets the same one-time prep. Turn on Developer options, enable USB debugging, and tick “always allow” on the first RSA prompt (what that prompt stores). Set the lock screen to none, because a PIN blocks the UI and these phones hold nothing but a test account. A phone with anything personal on it does not belong in this rack. I use USB. Wireless debugging works but gives you a second connection to keep alive.
Then the same init script on every phone, with SERIAL taken from adb devices -l:
adb -s SERIAL install -r app-release.apk
adb -s SERIAL shell pm grant com.example.app android.permission.POST_NOTIFICATIONS
adb -s SERIAL shell svc power stayon usb
adb -s SERIAL shell settings put system screen_off_timeout 1800000
adb -s SERIAL shell settings put global window_animation_scale 0
adb -s SERIAL shell settings put global transition_animation_scale 0
adb -s SERIAL shell settings put global animator_duration_scale 0
The first two lines use the package manager (more in the pm and am guide). The grant only works if the app declares the permission, and it stops the Android 13 and later notification dialog from ever appearing. It errors on the Android 12 handset, where that permission does not exist, so I skip it there. svc power stayon usb keeps the screen on while the phone is on USB power, which is the stay_on_while_plugged_in bitmask in Settings.Global. The timeout line (30 minutes, in milliseconds) is a fallback for the night the hub loses power. Zeroing the three animation scales stops taps landing mid-transition.
The runner is small. snap() saves evidence and step() retries and logs:
import logging, sys, time
from pathlib import Path
import uiautomator2 as u2
PKG = "com.example.app"
serial = sys.argv[1]
logging.basicConfig(filename=f"{serial}.log", level=logging.INFO)
log = logging.getLogger("rpa")
def snap(d, name):
out = Path("fail") / serial / f"{time.strftime('%Y%m%d-%H%M%S')}-{name}"
out.mkdir(parents=True, exist_ok=True)
d.screenshot(str(out / "screen.png"))
(out / "ui.xml").write_text(d.dump_hierarchy(), encoding="utf-8")
def step(d, name, fn, tries=3):
for n in range(1, tries + 1):
t0 = time.monotonic()
try:
if fn() is False:
raise AssertionError("wait timed out")
log.info("%s try=%d ok %.1fs", name, n, time.monotonic() - t0)
return
except Exception as e:
log.warning("%s try=%d %s: %s", name, n, type(e).__name__, e)
snap(d, name)
time.sleep(2)
raise RuntimeError(f"step failed: {name}")
d = u2.connect(serial)
d.implicitly_wait(10)
step(d, "launch", lambda: d.app_start(PKG, stop=True))
step(d, "new-ticket", lambda: d(resourceId=f"{PKG}:id/new_ticket").click())
step(d, "submitted", lambda: d(text="Submitted").wait(timeout=15))
implicitly_wait(10) makes every selector wait up to 10 seconds before it raises. wait() returns False on timeout, which step() counts as a failure.
what worked
Selectors in a fixed order: resource id, content description, text, xpath, and coordinates only when nothing else exists. Ids survive copy edits and translations, text does not. Because you own the app, a missing id is a one-line ticket for whoever builds it. In Jetpack Compose, test tags only reach UI Automator as resource ids when the root sets Modifier.semantics { testTagsAsResourceId = true }, so ask for that. I find ids in d.dump_hierarchy() output or in weditor, the inspector from the same project.
No bare sleeps. Every pause is a wait on the thing I expect next: 10 seconds by default, 15 for the slow submit screen, 30 for anything that crosses a network. Why android ui tests are flaky covers the timing causes. A fixed time.sleep(5) is too long every night except the night the network is slow, when it is too short.
Fresh state every night. Run adb -s SERIAL reboot, then adb -s SERIAL wait-for-device, then poll adb -s SERIAL shell getprop sys.boot_completed until it prints 1, then adb -s SERIAL shell pm clear com.example.app. The reboot costs the boot time and clears whatever the last run leaked. Clearing app data means login is part of every run, which is a useful test in itself.
Evidence on every failure. Beyond the screenshot and UI XML from snap(), I grab adb -s SERIAL logcat -b crash -d and adb -s SERIAL logcat -d -t 200. The logcat guide covers tag and priority filters if 200 raw lines is too noisy. The screenshot shows what the phone displayed and the XML shows what selectors could see. They can disagree, and you want both.
One process per phone, with a preflight. Separate Task Scheduler entries mean a hung phone stalls nobody. The preflight reads adb -s SERIAL shell dumpsys battery for level and temperature (tenths of a degree Celsius) and skips the run with an alert if temperature is above 400 (40.0 °C) or level is under 30. Those cutoffs are mine, not from any spec.
what broke
Phones dropping off adb. The symptom is adb devices printing offline or unauthorized instead of device. Three causes cover most of it. Two adb servers of different versions, usually Android Studio’s copy against a standalone Platform-Tools copy: the client prints adb server version (X) doesn't match this client (Y); killing... and restarts the server, which drops connections mid-run. Use one adb binary everywhere (client, server and daemon explains why there is a server at all). An underpowered hub or bad cable: use a hub with its own power supply and short cables. A revoked authorisation: recent Android can expire stored adb keys that go unused (Developer options has a toggle, disable adb authorization timeout), so check it on phones that sat idle. The preflight handles the rest. If state is not device, it runs adb kill-server and adb start-server, waits 10 seconds, checks again, and if the phone is still missing it skips that phone and messages me. No infinite loops.
Popups nobody scripted. A permission dialog, an app-not-responding box, an update banner, the keyboard covering the submit button. The selector fails and the screenshot shows why. Fix in layers: pre-grant permissions with pm grant, install the APK yourself and turn off Play auto-update for it, and use uiautomator2’s watcher to click known popups from a background thread (the README has the current syntax). d.press("back") closes the keyboard. I also turn off automatic system updates in Developer options so an OTA prompt is not waiting at 02:00. Those phones then fall behind on security patches, so keep them off untrusted networks.
Two tools fighting over one phone. Android allows one UiAutomation connection at a time, so with weditor open or an Appium session attached, the script gets errors mentioning UiAutomation instead of a screen. The fix is discipline: a lock file per serial, refuse to start if it exists, and close the inspector before the night run.
the numbers
These are planning figures for the reference build. Replace every input with your own.
- manual baseline: 3 phones x 15 minutes x 22 working days is 990 minutes, or 16.5 hours a month
- build: 16 hours for the first pass plus 4 hours of shakedown, so 20 hours
- upkeep: 2 hours a month for UI changes and dead handsets. This is a guess, and the number most likely to be wrong
- hours saved: 16.5 minus 2 is 14.5 a month, so the build hours pay back in about 1.4 months (20 divided by 14.5)
- hardware: S$250 per used handset and S$40 for a powered four-port hub, so S$790. These are my budgeting assumptions as of September 2026, not quotes. If the phones are already in a drawer, the cost is the hub.
Money ROI is 14.5 hours times your own loaded hourly cost, minus the hardware. I will not guess your hourly cost.
CAC does not apply. This saves hours, it does not acquire anyone. If you are automating part of a growth funnel, the acquisition cost lives in that funnel.
Scaling, as estimates. A fourth phone of the same model and Android version is about an hour: developer options, authorise, init script, scheduled task. A different manufacturer is closer to half a day, because dialogs and keyboards differ and some steps need new selectors. On the host, I would watch USB ports and hub power before CPU. I have not run this past a handful of phones, so anything larger is a guess.
lessons
- Ask for ids and content descriptions early. Selector work on an app you own is a ticket, not a puzzle.
- Retry the wait, not the tap. A retried submit can create a duplicate ticket, so retry idempotent steps only, or check state before acting.
- Write
snap()andstep()before flow number two. Evidence you did not collect is evidence you cannot get back. - When the view tree is empty (games, custom-drawn canvases), fall back to
d.click(x, y)or adb shell input tap, and keep phones on one screen density so coordinates hold. - Script the phone, not just the flow. One init script per phone means a dead handset is a short swap instead of a rebuild.
- Watch the batteries. Phones on chargers for months age badly, which is why the
dumpsys batteryline sits in the preflight. A swollen battery means unplug the phone and stop using it.
If the blocker is that you would rather not own a rack of phones, cloudf.one rents real Android phones in Singapore on dedicated hardware, each with a persistent Singapore mobile IP, controlled from the browser. It is Singapore only. There is no self-service adb: adb and Appium reach a rented phone only through a WireGuard tunnel that we set up on request (email [email protected]). So a nightly script like this one starts with asking for the tunnel, and I would not plan around it until it exists. It suits checks that depend on a Singapore mobile network. For checks that do not, your own phones are simpler.
would I do it again
Yes, for this shape of job: one app I own, a stable flow, a few phones, no API. The tooling is free and the failure evidence makes it easy to live with.
No, for anything with a real API. A script poking a screen is the most expensive integration you can choose, and it breaks whenever a designer moves a button. If an endpoint exists, call the endpoint.
Here is a position you can argue with. For one app on phones on my own desk I would pick uiautomator2 over Appium. Appium earns its setup cost when you need one client across Android and iOS, or a grid other people share. For three handsets it is more moving parts than the job needs. I have not benchmarked the two against each other, so that is a preference, not a measurement.
I would also stay on real handsets over an emulator. OEM skins, keyboards and battery behaviour differ on real hardware, and emulator vs real device testing lays out the trade.
The limit: this is a reference build sized for a handful of phones. I have no long-run data on a 20-phone rack and nothing here pretends to. The rest of the adb material is in the blog index.
Written by Xavier Fok
disclosure: this article may contain affiliate links. if you buy through them we may earn a commission at no extra cost to you. verdicts are independent of payouts. last reviewed by Xavier Fok on 2026-09-21.