driving a tablet with no hands

A follow-up to sixty dollars. That piece was about getting LineageOS onto a $60 $27 (on sale) tablet. This one is about the part that actually took the evening: configuring the thing afterwards, entirely over adb, without touching the screen.

The tablet was on a shelf across the room. Everything after the flash — launcher, grid, icons, fonts, a 103-line shader, a Home Assistant template widget — went in over adb shell.

That is not a hard problem in the way it first appears. Tap coordinates, dump the view tree, send text. Half a dozen primitives. The hard part is that almost none of the failures announce themselves. Automation that fails loudly is a bug you fix in a minute. Automation that fails confidently is three hours. Nearly every trap below is the second kind: something reported success, or reported nothing at all, and the screen quietly diverged from what the script believed was on it.

The screen you think you are looking at

Start with the worst one, because everything else is downstream of it.

uiautomator dump refuses to run while anything on screen is animating. It fails with:

ERROR: could not get idle state

This device runs a GLSL live wallpaper, so something on screen is animating essentially all of the time. The failure itself is fine — it is loud, it is specific, it tells you exactly what is wrong.

The damage is what a naive helper does next. The dump has a filename. If the dump command fails and the helper reads that file anyway, it gets the previous dump — a complete, well-formed, entirely obsolete description of a screen that is no longer displayed. Every subsequent decision is then made with total confidence about the wrong screen. Three separate wrong turns in one evening came from exactly that, and each one looked like a different bug until the pattern showed up.

The rule is unglamorous: retry the dump, and if it never succeeds, raise. Never fall through to a stale file. A missing dump is an error; a stale dump is a hallucination.

Two smaller versions of the same problem — the tree not saying what you assume it says.

Match on content-desc, not just text. The Quick Settings edit button carries no text at all. Its only label is the content description Edit order of Quick Settings. A matcher that only looks at text reports the button does not exist, on a screen where it is plainly visible. Several system controls are like this.

A hint is not an empty field. This one cost the most per character. uiautomator reports a Material text field’s placeholder in the same text attribute it uses for real content, so an empty field and a filled one are indistinguishable in the dump. The Home Assistant template widget’s field reads Enter template here when empty — and that string got stored as literal template text, and then rendered on the widget itself, on the home screen, as content.

Clearing it was its own small ordeal. Ctrl+A never selected anything. What worked: type, jump to the end, then backspace one character at a time, checking the field’s tail against what it should be after each one. It took exactly 19.

The taps that do not land

Never tap remembered coordinates. It is tempting, because reading the tree costs a second per action and coordinates are free. Replaying pixel positions from an earlier session on this device landed on sliders instead of rows and silently dragged the dock’s corner radius to 0 dp and its opacity to 30%. No error. Nothing to notice until you look at the tablet. Dump the hierarchy, match on the label text, pay the second — it cannot land on the wrong row.

Compose sliders answer to drags, not taps. A tap on a slider track does nothing whatsoever. What moves it is a synthetic DOWN → MOVE… → UP sequence. And since the visible track rarely maps to the range you assume, the reliable move is to drag hard to each end first and read the actual bounds off the slider’s own label — the grid sliders here turned out to be 3–10, not 1–whatever. Once you know the range, any value maps to an exact x position on the track.

Home-screen drags need waypoints. A straight-line drag from one cell to another passes over every icon in between, and hovering over an icon is what arms folder creation. The drop gets swallowed and nothing moves — again, silently, with the launcher in a perfectly reasonable state. Routing the same drag out through an empty column and then along the target row lands every time. The shortest path is the one that runs through the most hazards.

The text that does not arrive

input fails inside a device-side loop. A while read loop calling input text on the device fails on every iteration while the identical command works as a one-off — the 370-failure version of that story is in “sixty dollars”. One adb shell invocation per line, from the host, is the fix.

input text flips apostrophes to double quotes on long strings. A 954-character Jinja template arrived on the device reading states("sensor… where the source said states('sensor…, and Home Assistant answered:

TemplateSyntaxError: unexpected char

Both characters live on the same physical key, and the synthetic shift state drifts partway through a long send. It is not a corruption you can see coming, and the send reports success.

Sending the same text in 40-character chunks fixed it exactly. Base64-encoding each chunk before it crosses the shell is worth doing on top of that — it sidesteps quoting entirely, which is a category of bug you would otherwise meet later anyway.

And the one that never arrives at all

The last trap is not about input at all, but it has the same shape.

Shader Editor has no import function, which is why the wallpaper went in a line at a time. The apparently smarter move is to put the file on the device and fire an ACTION_VIEW intent at it. That silently falls through to the launcher, every time, no matter how correct the MIME type is.

The reason is that the app registers .*\.glsl as a path glob, not a type. A MediaStore URI looks like content://media/external/file/25. There is no .glsl anywhere in that path, so the filter does not match, and an unmatched implicit intent does not error — it just goes somewhere else.

Read the actual PatternMatcher out of dumpsys package before assuming a share will land.

What it adds up to

All of the above is now encoded in a helper script rather than in my head, which is the only honest place for it. But the general shape is portable to any device you are driving blind:

  • A failed observation must raise, never fall through to a cached one.
  • Assert on labels, not on coordinates — and read both text and content-desc.
  • Discover ranges empirically before setting values inside them.
  • Chunk long input, and encode it before it crosses a shell.
  • Route around anything that reacts to being hovered over.

Every one of those is a rule about not trusting a success. The device will tell you when it cannot do something. It will not tell you when it did something else.