Skip to content

fix(keyboard): verify Android typed text landed instead of reporting silent success - #592

Draft
latekvo wants to merge 4 commits into
mainfrom
fix/android-keyboard-verify-typed-text
Draft

fix(keyboard): verify Android typed text landed instead of reporting silent success#592
latekvo wants to merge 4 commits into
mainfrom
fix/android-keyboard-verify-typed-text

Conversation

@latekvo

@latekvo latekvo commented Jul 30, 2026

Copy link
Copy Markdown
Member

Symptom

QA, Bluesky on a physical Android phone. keyboard typed a plain sentence into a text field and reported full success; the field held something else.

typed: The quick brown fox jumps over the lazy dog. The quick brown fox jumps over.
got:   The quicbrown fox jmpover the lazy dog h quick brown fox jumps er.

The same string typed at the same moment into a different field on the same screen arrived character for character. The tool returned { typed, keys: 76 } either way — it had no idea.

Root cause

typeAndroidPhoneinjectAndroidTextadb shell input text <segment>. input text converts the string to KeyEvents through the virtual KeyCharacterMap and injects them back-to-back with no cadence. A field whose owner re-renders per keystroke — a controlled RN TextInput, a search box that re-queries on every change — drops events out of that burst. adb still exits 0, so nothing surfaced, and keys: text.length was returned unconditionally. That silent success is the defect; the dropped characters are a property of the field, which is why one field on the screen was fine and another was not.

Reproduced on the emulator, no Bluesky needed — one input text of the 76-char sentence into the Settings search box landed 45 characters:

field after one `adb shell input text`: "The quick brown fox jumps over the lazy dog. "   (45/76)
screenshot confirmed: "No results for The quick brown fox jumps over the lazy dog."

The same sentence injected in 8-character chunks with a pause between them landed all 76. That asymmetry is what the repair below exploits.

What changed

platforms/android-verify.ts (new) — the Android phone/tablet text path now:

  1. Reads the focused editable field before and after injecting, via the android-devtools blueprint's getHierarchy with clearCache: true. Mandatory: the helper's long-lived UiAutomation connection serves stale AccessibilityNodeInfo text, the same reason flows/flow-android-tree.ts passes it. Without it the "after" read can return the pre-typing value and the check is theatre.

  2. Classifies the reading three ways, because two of the shapes it can see are produced by both success and failure and acting on a guess is worse than saying so.

    • landed: text appears contiguously and the field grew by exactly text.length (a dropped character fails the substring half, a doubled injection the length half); or the field now reads precisely text and its previous value did not survive into it. The second clause covers the empty-field case, which the length arithmetic cannot: an empty EditText reports its HINT as its text attribute (confirmed on API 34 — the empty Settings search box reads text="Search settings"), so the baseline is not "".
    • indeterminate — reports, and retypes nothing. input text replaces a selection, so typing "argent" into a selectAllOnFocus field already reading "argent" succeeds and changes nothing, which is byte-identical to the same call having every key event dropped. Likewise a field that now reads exactly text while its old value survived as a prefix/suffix ("abc" correctly replaced by "abcdef" versus "abc" plus a partial landing of "def").
    • not-landed: everything else.
  3. Repairs, once. Deletes exactly the characters it added and retypes in 8-char chunks — a different cadence, not a blind repeat. Two proofs of what to delete, conservative first: (A) the field grew by added and before is recoverable by deleting one contiguous run of added chars → delete added; (B) the field holds only a subsequence of text, which proves it was empty and the baseline was a hint → delete after.length. Dropped key events delete characters without reordering or inventing any, so real corruption is always a subsequence — QA's corrupted string is one. Both proofs cap the count at text.length. If neither proof holds, nothing is deleted and the failure is reported instead: a field an input mask rewrote keeps its content.

    The undo is a backspace count rather than a rewrite, which is only correct because backspace deletes at the cursor and the cursor sits after whatever landed — so it undoes an insertion anywhere in the field, not only a suffix. Verified on device rather than assumed (second commit): with the cursor between the two characters of ab, input text XY gives aXYb and two backspaces give ab back. A property check over the planner is what surfaced that this premise was load-bearing and unpinned — every repair test had been inserting at the end of the field, where a planner that assumed a suffix insertion behaves identically.
    The field's identity — checked between the two reads so a focus change is caught — is resource-id + class + bounds origin. resource-id alone is not enough: it carries the RN testID, so every untagged TextInput dumps an empty one and an auto-advancing form (an OTP code across boxes) would have the retype land in the next field. The origin distinguishes fields while surviving the width change typing causes (the Settings box's right edge moves 1080 → 933, origin fixed).

    A retry that cannot reach the device is caught: the undo runs before the retype, so an adb failure between them can leave the field emptier than the call found it, and that is reported rather than surfacing as an error implying nothing happened.

  4. Never reports silent success. verified?: boolean + note?: string on KeyboardResult, mirroring open-url's note convention. verified: true = read back and confirmed. false = read back and wrong, after the retry. Absent = no read-back happened, note says which reason (no editable focus / password field, whose text uiautomator masks / read failed or focus moved / helper unavailable). A read-back that cannot run never fails the call — the keystrokes are already on the device by then, so throwing would report a failure that may not have happened.

Latency, gated deliberately. Verification is unconditional when the fast helper read is available, and skipped-with-a-note when only uiautomator dump would be — so a locked-down device (exactly the device where adb install -t is blocked) does not pay two dumps per typed string. Measured on one emulator, screen and string, 76 chars into the Settings search box: 1.6-1.8 s unverified, 1.9-3.4 s verified (corrected in 1a5c4de — the five verified trials below are 3.41/1.96/1.91/2.20/3.05 s, none of which fall in the 3.5-4.0 s originally stated). I did not gate on text length: the corruption is a property of the field, not the length (QA's short string was fine in one field, the long one corrupt in another), so a threshold would leave the silent-success bug reachable under it and make the guarantee depend on a magic number. The 500 ms settle is kept on both reads on purpose — dropping it from the "before" read saves 500 ms but risks reading before the framework has published focused="true", turning a healthy call into a spurious "no editable field had focus" note. The numbers are in the module docstring so this is arguable with data.

The flow type directive now honours the verdict. It discarded the keyboard result, so a flow still greened a type step for text that demonstrably did not land — the same defect one layer up. An explicit verified: false now fails the step, carrying the tool's note as the reason and skipping the submitting Enter (submitting a field holding the wrong value is worse than not submitting). An absent verified still passes: not checked is not failed, which is what keeps iOS, Chromium, Vega and helper-less Android working.

Secrets. The read-back holds the resolved plaintext of a {{secret:…}} type. Notes are value-free by construction — counts and structural facts only, never the field's contents — and execute additionally runs the new redactSecrets over note on the secret-bearing path, so the value-free property of one module is not the only thing between a secret and the transcript. redactSecretsFromError still covers the error path (it now shares that helper). Both halves are pinned by tests. The counts in a mismatch note do reveal a typed secret's length; keys already exposed exactly that for every secret type, so this bounds the leak at what the result already had rather than eliminating it, and the field JSDoc says so instead of claiming counts are leak-free.

Verification

Unit — test/keyboard-android-verify.test.ts (new, 51 tests) + extensions to keyboard-android.test.ts, keyboard-secrets.test.ts and flows/flow-type-focus.test.ts:

  • Fault injection through the real makeAndroidImpl().handler with a stub registry serving a scripted sequence of hierarchies, one per getHierarchy call, so a corrupted read-back drops in at a chosen point. Proves the mismatch is detected, the repair runs (asserting the exact ordered adb command list: burst → input keyevent 67 ×8input text 'abcdefgh'input text 'ijkl'), and that a still-failing repair reports verified: false with the exact counts.
  • Every "cannot verify" path, asserting the text is still typed and exactly one input text reached the device.
  • The secret gate from both sides: the real Android path never puts the field's text in the result on any outcome, and the execute boundary scrubs a value-bearing note to its placeholder.

Mutation sweep — 32 mutations applied one at a time to the merged behaviour (both halves of the classification, each ambiguity clause, the identity's bounds origin and the full-bounds over-correction, plan A/B and their ordering, the deletion cap, the suffix-only planner, clearCache, the raised node cap, the truncation diagnosis, the document-order walk, the focus-moved note, the repair's chunking and its error handling, the 64-per-call undo chunking, the before-read injection, the secret scrub, the flow gate in both directions — swallowing verified: false and over-strictly failing on an absent one). 32/32 turned the suite red. Three sweeps were needed: the first found the dropped-character tests being caught by the length check rather than the substring check; a property check over the deletion planner found the unpinned mid-field undo; the third found the ambiguity clause that only matters when the field contains but does not equal the text, and the entirely-uncovered flow gate.

E2E — two emulators across the two passes, emulator-5556 (Pixel 6 / API 34) and emulator-5554 (Pixel 3a / API 34), both allocated via request-device, driven over HTTP (ARGENT_PORT=3097 node dist/index.js start, POST /tools/keyboard), read back through the tool-server's own describe. (A host-side uiautomator dump must NOT be used for the read-back while the helper runs — the two fight over the UiAutomation connection and corrupt each other's reads. That cost me an hour of bogus measurements and is worth knowing.)

### verified typing of the 76-char sentence into the focused search box
  trial 1   3.41s  {"keys": 76, "verified": true}   describe reads back 76/76 chars, == requested
  trial 2   1.96s  {"keys": 76, "verified": true}   describe reads back 76/76 chars, == requested
  trial 3   1.91s  {"keys": 76, "verified": true}   describe reads back 76/76 chars, == requested
  3/3 agreed: verified=true AND the screen holds the exact string

### non-empty baseline (typed "a" first, then the sentence into the busy field)
  trial 1   2.20s  {"keys": 76, "verified": true}   field == 77 chars, "a" + sentence
  trial 2   3.05s  {"keys": 76, "verified": true}   field == 77 chars, "a" + sentence

### failure path: 12 letters into the dialer's digits-only field
  {"typed": "abcdefghijkl", "keys": 12, "verified": false}
  note: The typed text did NOT land in the focused field: it holds 0 characters where 12 were
        expected, and retyping it in smaller chunks did not fix it either. …
  (on main this call returns {typed, keys: 12} — full success for a field that received nothing)

### ambiguous path: select-all (Ctrl+A) then retype the SAME text
  verified=None
  note: … equally consistent with the text having landed and with it having been dropped …
        Nothing was retyped, because doing so on this evidence risks entering the text twice.
  field afterwards: 'argent'  -> CORRECT, not doubled
  (before the review fix this produced 'argentargent' AND reported verified: true)

### no editable field has focus (HOME screen)
  2.43s  verified=None   note: … no editable field held input focus …

### read-back genuinely unavailable (helper uninstalled + bundled APK hidden)
  1.64s / 1.64s / 1.75s  verified=None  note: … the android-devtools helper is not available …

Plus the device measurement the repair arithmetic rests on: with the cursor between the two characters of ab, input text XY gives aXYb and two backspaces give ab back — N backspaces remove exactly the N injected characters wherever the cursor sat.

Gates, all from the worktree root: npx tsc --build, npx prettier --check ., npx eslint . --max-warnings 0, npm run typecheck:tests -w @argent/tool-server, npm test -w @argent/tool-server296 files, 3125 passed, 1 skipped, 0 failures (the known forwards SIGTERM and lens-tools-platform-gate flakes did not fire).

Deliberately left out

  • iOS / Chromium / Vega / Apple TVadb shell input text is the transport that loses characters; none of the others synthesise a KeyEvent burst through a KeyCharacterMap.
  • Android TV (platforms/tv.ts, the nearest twin — it funnels through the same injectAndroidText). It shares the "no read-back" shape but not the exposure: the TV backend already types one space-free word per input text call with a KEYCODE_SPACE between words, i.e. the chunked cadence the phone path has to fall back to after catching a drop. And typeTv is shared with Apple TV, whose HID transport cannot read a field back at all, so verifying half its callers would mean adding a platform branch to a function that deliberately has none. Noted in a comment there.
  • The editable probe diverges from the twin. blueprints/android-tv-control.ts uses /EditText/; this uses /EditText|AutoComplete/, because AutoCompleteTextView and SearchView$SearchAutoComplete are EditText subclasses whose simple name lacks "EditText". There the verdict only labels an element textfield for the agent to read; here it decides whether a correctness check runs at all, so a missed subclass would silently disable verification. Left the twin alone to keep TV describe output unchanged; commented at the definition.
  • Could not prove: I never got a natural drop to occur while the tool was driving, so the repair-succeeds-on-retry path is proven by unit-level fault injection plus the two raw-adb measurements above (burst 45/76, chunks 76/76), not by one end-to-end run that dropped and recovered. The mismatch-detection and mismatch-reporting halves ARE proven end to end (dialer field). Physical-phone behaviour is unverified — I only had an emulator.
  • Verification is conditional on the android-devtools helper. It installs over adb install -t, which works on a normal developer-enabled physical phone, so the reporter's device should get the real check — but an MDM-locked device that refuses the install gets the note instead of a verdict, by design (the alternative is two uiautomator dumps per typed string). The guarantee is "verified, or told it could not be", never "always verified" — which is also why an absent verified must never be read as a pass.
  • keys still counts the characters the call asked to type, not presses a repair re-sent; making it non-deterministic per repair seemed worse than leaving it as the requested count.

Interaction with the open keyboard PRs

Both touch the same lines; this is based on main and will conflict textually with either.

  • fix(tool-server): reject a keyboard call carrying both text and key #579 fix/android-keyboard-key-order rewrites typeAndroidPhone end to end (reorders the key/text branches, drops the up-front assertTypeableAndroidText) and rewrites the Returns { … } line. This change also rewrites both. The read-back call site moves with whichever branch order lands; the up-front validation matters here for the same reason it did there — an un-typeable text must reject before the helper is resolved, so no APK install happens for a rejected call.
  • feat(tool-server): clear on the keyboard tool #580 feat/keyboard-clear (stacked on fix(tool-server): reject a keyboard call carrying both text and key #579) adds cleared?: boolean to KeyboardResult with a docblock that states Android "does not read the field back" — this change makes that sentence wrong, so it needs updating on merge. feat(tool-server): clear on the keyboard tool #580 also adds its own read-back machinery for the clear path: utils/android-ui-dump.ts (dumpAndroidUiXml), plus readHierarchy and measureFocusedTextLength in android-input.ts, and exports attrIsTrue from the uiautomator parser. That overlaps this module's focused-field read and should be consolidated to one helper rather than shipped twice — notably feat(tool-server): clear on the keyboard tool #580 reads via uiautomator dump while this reads via the android-devtools helper, and a host-side uiautomator dump and the helper's UiAutomation connection fight over the accessibility connection and corrupt each other's reads (I hit exactly this while building the E2E harness). Worth resolving deliberately whichever merges second.
  • feat(tool-server): clear on the keyboard tool #580's keyboard-clear.test.ts asserts ~28 exact ordered adbShell command lists on the Android path; a read-back adds adb round trips, so those go red on the merged result even with no textual conflict.

Happy to rebase this onto whichever of the two lands first.


Follow-up: 1a5c4de

Review found a data-corruption path in the repair, plus a set of notes that describe something other than what the code did.

The undo could read a hint as prior content and double the value

FocusedField.text already documents that an empty EditText reports its HINT. What the undo did not account for is that a hint can share an EDGE with the typed text, which the docstring explicitly ruled out ("a hint shares no prefix or suffix with the typed text"). It is not exotic — a URL bar hinted https:// under https://example.com, a quantity box hinted 0 under 100.

Worked through the real functions, hint https://, typed https://example.com, first burst landing 12 characters:

added                = 12 - 8 = 4
coversByEdges(...)   = true        -> plan A: 4 backspaces
field after undo     = "https://"  (the hint, now real text)
field after retype   = "https://https://example.com"
classifyTypedText    -> after.includes(text) && 27 === 8 + 19  ->  "landed"

So the field ends up holding a doubled value, the caller is told verified: true with no note, and flow-actions.ts greens the step and presses Enter. The corruption is shaped to satisfy the success branch, so it can never surface as a failure. A sweep over hint/text/drop-point combinations produced 35 corrupting repairs, all 35 classified landed; the same sweep over real (non-hint) baselines produced none.

Both proofs can hold at once, and where they do they disagree about which characters are ours. That overlap now returns null and the field is left untouched. Where the baseline cannot have been a hint — XY is not a subsequence of the typed text — plan A still applies, so the undo is not disabled for real content.

The test that pinned the old behaviour (plannedUndoDeletions("a", "abcdefghi", "abcdefghijkl") === 8, "prefers plan A over emptying the field") was pinning the trigger: "a" there is equally a prior character and a hint. It now asserts the decline.

Notes that said something other than what happened

before after
truncated read-BACK "focus moved to a different field … may have been split across both fields" reports the truncation, as the baseline read already did
nothing focused afterwards same focus-moved sentence, inventing a second field reports focus lost, with no second field claimed
any blocked read AFTER the repair "nothing was retyped" says the field was backspaced and retyped
repairFailedNote "the 12 characters were removed" while 8 backspaces were issued reports the count actually deleted
mismatchNote "it holds 10 characters where 12 were expected" for a field that held 2 before, so 4 were lost reports what was typed and what the field holds in total, and says the total is not a loss count
completedMsg "Entered text" over a verified: false appends "(text did not land)"; an absent verified stays quiet

readAfter discarded the truncated flag readFocusedField returns and treated "nothing focused" and "a different field focused" identically — the asymmetry with the baseline read, which has a dedicated note for exactly that evidence, was the finding.

Secret notes are no longer substituted over

redactSecrets ran over the advisory prose. Because it replaces bare occurrences of the value anywhere, a two-character secret rewrites ordinary words and a numeric one swallows the count the note exists to report — on notes a successful call returns:

ARGENT_SECRET_W="or"   -> "... a full `uiautomat{{secret:W}} dump` per call ... bef{{secret:W}}e relying on them."
ARGENT_SECRET_PIN="12" -> "{{secret:PIN}} characters were typed and the field now holds 10 in total."

The second is worse than noise: it reads as though the field holds the credential. And the substitution could not have closed the gap it was defending — it matches whole values only, while a dropped-character read-back by definition holds a PARTIAL secret. The guarantee that actually holds is that every note is value-free by construction, which is pinned directly. Errors are still scrubbed; they quote the input text argv verbatim. redactSecrets is now module-private, since nothing outside redactSecretsFromError uses it.

Flagging this one for your call — it reverses a documented defence-in-depth decision. The corruption is real and lands on healthy calls, but if you would rather keep the scrub, the alternative is to withhold a colliding note entirely rather than rewrite it.

Claims corrected against evidence

  • Password fields are masked. Run through the helper on a Pixel_3a_API_34 emulator, an EditText holding SecretPass1 reads back text="•••••••••••", 0 plaintext occurrences; uiautomator dump agrees. The module doc and the agent-facing note both asserted the opposite ("NOT masked … attrs.text still holds the secret"), while this PR's own test title says "whose text is masked". Skipping is still right, for two reasons now stated: the comparison could not work, and the masking is a platform default rather than a guarantee this code can enforce.
  • The freeze needs no restart is a fix(describe): read the Android tree uncached so describe can't serve a stale screen #591 finding, but the same wording appears here via the shared cache rationale; left to that PR.
  • A whole-field selection replaced by a shorter string is accepted as landed, not reported not-landed as the "known limitation" paragraph claimed. Narrowed to the inner-selection case, which does behave as described.
  • The multi-window reference pointed at describe/platforms/android/index.ts, which has no multi-window material. Now points at the helper's own captureMode/windowCount.
  • "The settle is deliberately kept on both reads" describes code that is not there — neither read passes waitForIdleMs; the 500 ms is the blueprint default.
  • The helper-unavailable note called the helper "not available on this device", a permanent property, for a resolveService failure that also covers a spawn that timed out.
  • flow-actions.ts narrated the pre-diff state ("greened a type that typed something else"), which reads as nonsense once the diff is history.

Tests

Mutation-checked; each of these previously survived green:

mutation before after
plan-A-first (the shipped behaviour) 0 fail 3 fail
collapse readAfter's three outcomes 0 fail 3 fail
delete the inter-chunk pause 0 fail 1 fail
after === before always indeterminate 0 fail 1 fail

The last two are the gaps the review turned up: the pause has JSDoc calling it "the whole point" but only the chunk size was pinned, and no test covered before === after with the field holding none of the typed text — the reported digits-only case. Also added: the truncated read-back, focus lost outright, the post-repair notes, and a short/numeric secret leaving a note intact. androidNote now resets in afterEach so one failure no longer cascades into the next test.

Verification

  • npx tsc --build packages/tool-server — clean
  • npx tsc --noEmit -p packages/tool-server/tsconfig.test.json — clean
  • npx eslint packages/tool-server/src packages/tool-server/test — clean
  • npx prettier --check — clean
  • full tool-server suite — 3135 passed, 1 skipped, plus one unrelated failure in vega-cli-timeout.test.ts ("reaps the ENTIRE worker tree on timeout") that passes 9/9 in isolation; it is a real-subprocess test under full-suite load, touched by neither this PR nor the follow-up.

Left alone deliberately

keyboard's description says "when both are given, the text is typed first and the key is pressed after it", but platforms/android.ts presses the key FIRST and keyboard-android.test.ts asserts that order as the source contract. The line is pre-existing on main, and #579 is described as reordering those branches — fixing it here risks inverting a statement that PR makes true, so it wants its own change.

latekvo added 4 commits July 30, 2026 15:45
…silent success

`adb shell input text` converts the string to KeyEvents through the virtual
KeyCharacterMap and injects them back-to-back with no cadence. A field whose
owner re-renders per keystroke drops events out of that burst, and the transport
gives no signal: adb exits 0 having "typed" the whole string, so the tool
returned `{ typed, keys: text.length }` for text that never arrived.

Reproduced on a Pixel 6 / API 34 emulator against the Settings search box: one
`input text` of a 76-character sentence landed 45 characters and the tool
reported full success. The same sentence injected in 8-character chunks landed
all 76.

The Android phone/tablet backend now reads the focused editable field before and
after injecting (android-devtools `getHierarchy`, `clearCache: true` — the
helper's long-lived UiAutomation connection otherwise serves stale text) and
compares. On a mismatch it undoes exactly the characters it added and retypes at
the chunked cadence, once; a field whose prior content cannot be provably
restored is left untouched. The result carries `verified?: boolean` and an
advisory `note?: string` following the `open-url` convention, so a call that
could not verify says so rather than implying the text landed.

Notes are value-free by construction (counts and structural facts, never the
field's contents) and `execute` additionally scrubs them on the secret-bearing
path, so a resolved `{{secret:…}}` read back off the screen cannot reach the
transcript.

iOS, Chromium, Vega and the TV backends are unchanged: `adb shell input text` is
the transport that loses characters, and Android TV already types one word per
call at the chunked cadence.
…elies on

The repair converts a mismatch into a backspace count, which is only correct
because backspace deletes at the cursor and the cursor sits after whatever
landed — so it undoes an insertion anywhere in the field, not just a suffix.
Nothing pinned that: every repair test inserted at the end, where a planner that
assumed a suffix insertion would behave identically.

Confirmed on device (Pixel 6 / API 34) before pinning it: with the cursor between
the two characters of "ab", `input text XY` gives "aXYb" and two backspaces give
"ab" back. Recorded in the planner's docstring, since the arithmetic is otherwise
justified only by reasoning about a transport whose behaviour is worth stating as
measured.
Review of the read-back turned up three ways it could make things worse than the
silent success it replaced.

A correct type could be doubled. `input text` replaces a selection, so typing
"argent" into a `selectAllOnFocus` field that already reads "argent" succeeds and
changes nothing — byte-identical to the same call having every key event dropped.
The comparison read that as failure and retyped, leaving "argentargent" and
reporting verified. The same shape appears when a field already reads exactly the
typed text and when an empty field's hint equals it. The verdict is now
three-way: readings consistent with both success and failure are `indeterminate`,
which reports and retypes nothing. The other ambiguous shape is a field that now
reads exactly the text while its previous value survived as a prefix or suffix
("abc" replaced by "abcdef" versus "abc" plus a partial landing of "def").

The repair could type into the wrong field. Field identity was `resource-id` plus
class, and `resource-id` carries the React Native `testID`, so every untagged
`TextInput` and Compose `TextField` dumps an empty one and all of them compared
equal. On an auto-advancing form (an OTP code across boxes) focus moves mid-call,
the guard did not fire, and the retype went into the next field. Identity now
includes the bounds origin, which distinguishes fields while surviving the width
change typing causes — measured: the Settings search box's right edge moves from
1080 to 933 with the origin fixed.

A failed retry reported total failure. The undo runs before the retype, so an adb
failure between them left the field emptier than the call found it while the
error said the typing failed. It is now caught and reported as such.

Also from the review: raise the read's node cap to the flow tree's 12000, since
the helper's 5000 default truncates a dense screen and a capture that stops before
the field is indistinguishable from a screen with no focused field — reported as
truncated rather than as "nothing had focus"; give a focus change its own note
instead of a read failure; and correct four claims that were wrong. Android TV
shares this transport and is NOT covered (it splits only on spaces, so a
space-free email or username is still one burst) — the tool description no longer
implies other platforms are immune. A password field's text is not masked in the
dump, so the reason to skip it is that reading a credential back would put it in
the result. The note's character counts do reveal a typed secret's length, which
`keys` already exposed. And the cost analysis now includes the helper's cold
start, not just the warm socket.

Finally, the flow `type` directive discarded the result, so a flow still greened a
step for text that demonstrably did not land — the same defect one layer up. It
now fails the step on an explicit `verified: false`, carrying the tool's note as
the reason and skipping the submitting Enter, while an absent `verified` (not
checked) still passes.
… hint from

An empty EditText reports its HINT as `text`, which this code already knew. What
the undo did not account for is that a hint can share an edge with the typed
text — "https://" under "https://example.com", "0" under "100". Plan A then
measures growth against a prior value that was never in the field, deletes
hint.length too few, and the retype lands on the residue. The result is a doubled
value ("https://https://example.com") whose length is exactly before + text, so
classifyTypedText's first branch calls it landed: verified true, no note, and the
flow `type` gate greens the step and submits it.

Both proofs can hold at once, and where they do they disagree about which
characters are ours, so the overlap now returns null and the field is left alone.
Where the baseline cannot have been a hint, plan A still applies.

Also on the read-back:

- readAfter discarded the `truncated` flag and treated "nothing focused" and "a
  different field focused" alike, so all three reported "focus moved to a
  different field … the text may have been split across both fields" — inventing
  a second field for a capture that simply stopped short. They now report
  separately, and the truncated case says what the baseline read already says.
- Those notes claimed "nothing was retyped" on the post-repair read, where the
  field HAS been backspaced and retyped. Saying so invites a third typing of the
  value.
- repairFailedNote reported text.length as the number of characters removed; the
  undo deletes `deletions` (8 backspaces were issued while the note said 12).
- mismatchNote compared a whole-field length against a typed count: a field
  holding 2 prior characters, typed 12, 8 landed, read "10 where 12 were
  expected" — 4 were lost, not 2. It reports what is known instead.
- completedMsg said "Entered text" over a verified:false result, putting the
  silent success back in the log line a user watches.

The note is no longer substituted over for secrets. Every note is value-free by
construction and pinned as such; the substitution replaced bare occurrences
anywhere, so a two-character secret rewrote ordinary words ("uiautomator" →
"uiautomat{{secret:W}}") and a numeric one swallowed the character count, on
notes a successful call returns. It could not have caught a leak either — a
dropped-character read-back holds a PARTIAL secret, which whole-value replacement
never matches. Errors are still scrubbed: they quote the argv verbatim.

Corrects measured claims that the evidence does not support: the password field
reads back as bullets rather than plaintext (API 34, through the helper), the
verified latency range is 1.9-3.4 s and not 3.5-4.0 s, a hint CAN share an edge
with the typed text, and a whole-field selection replaced by a shorter string is
accepted rather than being the stated limitation.

Adds coverage for the shapes that were unpinned: the field that received nothing
at all (the reported digits-only case), the inter-chunk pause the repair depends
on, the truncated read-back, focus lost outright, and the post-repair notes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant