adb shell input tap 540 1720 is the first thing anyone finds, and it deserves its reputation. No root. Nothing installed on the phone — no helper APK, no accessibility service, no runtime. Turn on USB debugging, plug the phone in, and the tap lands.
NOISEY's own tap bottoms out in exactly that command. There is no cleverness underneath it and no reason for there to be. So this is not a post about input tap being the wrong tool.
It is a post about the gap between the first phone and the second one. A coordinate pair is not an instruction — it is a record of where something used to be. Four separate things make that record stop being true, and all four fail quietly.
One — a coordinate names a place, not a thing
The obvious version of this is screen size, and it is the least of it. You do not need a different phone to break a recorded tap; you need a notification banner, a one-row taller sheet, or an app that shows a “what's new” card on first launch after an update.
Our own rack is twenty boards of the same handset — nineteen of them Snapdragon variants differing only by carrier region. Even that is not uniform. A recorded run that swiped seven times to reach TikTok in the app list worked only on the board it was recorded on, because the preloaded apps differ from board to board and TikTok therefore sits in a different place on each. Same model, same Android version, different position.
Twenty phones is not one coordinate set. It is twenty, and they drift apart independently.
Two — uiautomator dump is a snapshot, not a subscription
The standard second step is adb shell uiautomator dump: pull the view hierarchy as XML, find the node you want, read its bounds, tap the centre. That is genuinely the right idea, and two properties of the tool shape everything you build on it.
It is slow. A dump costs roughly 1–3 seconds on these boards. And it refuses outright while the screen is moving, with ERROR: could not get idle state. — which describes most of the first second after any tap you just made.
So the naive loop — dump, find, tap, dump, find, tap — is both slow and wrong. Slow because you pay 1–3 seconds per selector. Wrong because the bounds you extracted were true at dump time, and anything animating, lazily inflating or still loading an image has moved since.
Two rules fall out, and both are in our device layer:
- Dump once per screen state and match every selector for that screen against the cached tree. Never dump per selector.
- Retry the idle error and nothing else. Ours makes four attempts with a backoff that grows 400 ms each time, and returns any other failure immediately — retrying a
SecurityExceptiononly spends seconds to arrive at the same answer.
When you are waiting for a screen to appear, poll for the element rather than sleeping. There is nothing to subscribe to — a dump is a snapshot — so “wait for the Post button, up to 10 seconds” is both faster and more truthful than sleep 5: it returns the moment the button exists, and it fails loudly when it never does.
Three — nothing tells you it missed
input tap exits 0 whether or not there was anything under your finger. There is no such thing as missing. The command reports that it injected an event, which it did.
Watching a mirror does not save you either. A tap on a static screen produces no frames at all, so a successful tap on a button that does nothing and a tap into empty space look identical on the stream.
The only fix is to verify by consequence. Three things, in order of how much they are worth:
- Assert the screen you expected. A routine that ends with “and now the word Posted is on screen” has proof; one that ends with a tap has a hope.
- Stop at the first failed step. A routine is a sequence, not a best-effort list. Carrying on past a step that did not happen is how you end up asserting success on a screen you never reached.
- Screenshot on failure and attach it to the record. Without the picture,
no node matched Text("Post")on board fourteen is a guessing game. With it, you can see the app was showing a login wall.
There is a fourth assertion worth building that almost nobody does: assert the absence of a screen. A consent gate, a rate-limit notice, an “unusual activity” warning — when one of those appears, the correct behaviour is to stop and report it to a person. Retrying in a way that happens to route around a limit the platform just set is not reliability engineering, and we do not build it.
Four — ids move, and on the apps you care about they were never still
Every tutorial tells you to prefer resource-id. That advice is right for most apps and exactly backwards for the ones people actually want to automate.
Measured, on TikTok 46.3.3 (com.zhiliaoapp.musically): the first screen's hierarchy carries ids a2y, u1k, yk7, uc1, f07. Those are R8/ProGuard-minified names. They are assigned by the build, they change between releases, and sometimes between builds of the same release. The one readable id on that screen is visual_area, and it survives precisely because the accessibility layer refers to it by name.
The other half of the problem is generic ids. android:id/title is shared by every screen using a stock layout. button2 means “the second button”, which is a fact about one screen's arrangement and nothing else. Learned on one handset they look precise; replayed on another they press whatever is there.
The scale of the ordinary version is documented elsewhere too: one reported case saw a single UI refresh break 40% of an Android suite's selectors. Selector rot is the operating condition, not an incident.
What doing it properly looks like
Resolve elements by a ranked strategy, and let the ranking flip when the app tells you it has been minified.
Six rules that came out of running this on real hardware:
- Treat a very short id as minified. Ours calls three characters or fewer minified and skips straight to the label. Four was too greedy — it swallowed
com.x:id/send, which is a perfectly real id. - Position is never part of a selector. A recorded run says “the eighth thing on the screen I was looking at”. One extra notification and the eighth thing is something else. Replaying a position is replaying a coincidence.
- Pair the selector with
clickablewhen the node is clickable. Where a label and its button are separate nodes, a tap on the label does nothing at all on One UI, and does it silently. - Strip volatile values out of labels.
Storage, 12.4 GB usedshould become “the row whose label contains Storage”, because 12.4 will not be 12.4 tomorrow. - Refuse generic names at authoring time, with an explanation. Accepting
button2silently is accepting a coin flip on every handset you have not tested. - Refuse destructive labels unless the routine says so out loud. Ours will not press anything reading uninstall, delete, erase, factory reset, clear data or remove account unless that step was written with permission to. A selector drifting onto a destructive control is the one failure a fleet tool must not have.
Keep a coordinate as a declared fallback if you like — but log it when it fires. A routine quietly degrading to coordinates should be visible in the run record, not invisible.
What about an accessibility service?
It is a fair question, because on paper it is the better mechanism: an AccessibilityService gets events pushed to it, sees the tree without a 1–3 second dump, and needs no cable. Two reasons NOISEY does not drive apps that way.
First, Google's Play policy scopes the API to accessibility purposes. Second, it means installing an agent on every handset. Our device layer needs nothing on the phone at all, which on twenty boards is a real operational difference — nothing to update, nothing to re-grant, nothing to keep signed.
NOISEY's Android companion does use a notification listener, and that is a different thing entirely: read-only, six apps, a denylist that outranks the allowlist, and anything shaped like a verification code thrown away on the phone before it is written down. The docs spell out the whole list.
What none of this fixes
A ranked selector does not stop an app changing. It changes what happens when it does: instead of tapping the wrong control on nineteen handsets, the routine fails at a named step with a picture of the screen. That is the whole win, and it is worth having.
Everything else is a design problem — how fast you find out and how much you lose in between — and, once there is more than one phone in the room, an architecture problem: what breaks between one handset and twenty.
In NOISEY this is the routine layer: pick the element off the phone and it works out the most durable way to find it again, then run it once and watch the steps colour in before it goes anywhere near a schedule. When something does fail later, the error and the screenshot are on the job. Nothing here is a feature you cannot build yourself — it is just the shape of the work, and it took a rack to learn it.