← All posts

adb shell input tap is not automation — four reasons it fails on the second phone

10 min read

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.

The command is identical in all three cases; only the screen underneath it moved. The third phone is the dangerous one, because a tap on empty space reports exactly the same success as a tap on Post.

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:

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:

Steps 1 to 3 succeeded, so the failure is not where the routine broke — it is where the screen stopped matching. The attached capture answers it in one look: a consent gate is sitting over the button.

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.

Left is the ordinary case and the order every tutorial gives. Right is what the same four attributes are worth once R8 has renamed the ids — which is the case for most large consumer apps.

Six rules that came out of running this on real hardware:

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.

Read the docs →