Skip to content

Android replay: non-unique framework id android:id/title used as primary identity → systematic false divergences (FDR 1.0) #1269

Description

@thymikee

Summary

On Android, replay of native list UIs produces systematic false divergences (measured FDR 1.0
across two independent Settings flows) because the annotator selects the non-unique framework
resource-id android:id/title
as the primary identity for list-row text. iOS is unaffected (FDR 0),
because its AX cells expose unique per-cell identifiers.

This was found during a controlled false-divergence experiment (wave-3 E1) on main @ 6efe54451
(daemon v0.19.3). The app is unchanged between record and replay — every divergence here is false by
construction.

Root cause (from the divergence targetBinding)

A recorded Android get/press on a list row annotates like:

get "text" "id=\"android:id/title\" || role=\"textview\" label=\"Network & internet\" || label=\"Network & internet\""
# target-v1: { "id":"android:id/title", "role":"textview", "label":"Network & internet",
#             "sibling":1, "viewportOrder":1, "ancestry":[linearlayout, recyclerview, framelayout, ...] }
  • android:id/title is a shared framework resource-id present on every titled row — on the Settings
    root it matches 11 elements (matchCount: 11).
  • The recorded ancestry is label-less generic container roles (linearlayout / recyclerview /
    framelayout), so it contributes no disambiguation — leaving only sibling/viewportOrder position.
  • On replay the position→element mapping shifts (a conditional row appears, or the shared-id set
    reorders), so the id-path resolves the wrong element. The identity verifier then catches the label
    mismatch and refuses:
targetBinding: {
  classification: "identity-mismatch",
  matchCount: 11,
  recorded: { id: "android:id/title", role: "textview", label: "Network & internet" },
  observed: { id: "android:id/title", role: "textview", label: "Apps" },
  mismatches: [ "label: recorded=Network & internet observed=Apps" ]
}

This is a safe failure, not a silent mis-bind

The verifier did the right thing — it prevented acting on the wrong element ("Apps") and failed loudly.
The cost is a false divergence, not a wrong action. So the fix belongs upstream in Android identity
selection
, not in loosening the verifier.

Proof it's the shared id (not Android capture)

The same flow with label="..."-only selectors (no android:id/title) replays 3/3 clean. Android
capture and the label resolver are fine; the defect is choosing a non-unique framework id as primary.

Reproduction

  1. Build a daemon from main. On an Android emulator, record a native list flow, e.g. Settings root:
    open com.android.settings --relaunch --save-script=f.ad, then get text 'label="Network & internet"',
    get text 'label="Connected devices"', close --save-script.
  2. replay f.adREPLAY_DIVERGENCE / identity-mismatch at the first get; the recorded selector
    leads with id="android:id/title".
  3. Hand-author the same flow with label="..."-only gets → replays clean, repeatedly.

Measured (wave-3 E1, 20 replays/flow, unchanged app)

Flow Platform FDR
Settings → General → About iOS native 0.00 (20/20 clean)
test-app Form fill iOS RN 0.00 (20/20 clean)
Settings/Battery (default annotation) Android native 1.00 (20/20 diverge)
Settings root, label=-only selectors Android native 0.00 (3/3 clean)

Recommendation

At annotation time on Android, do not use a non-unique resource-id as the primary identity selector:

  • Treat framework/shared ids (namespace android:id/*, and any id whose match-count > 1 at capture) as
    non-selective — demote below the visible label, or omit them from the selector chain.
  • Equivalently, the resolver could treat a clause that matches N > 1 and requires positional
    disambiguation as low-confidence and fall through to a more selective later clause (the label clause
    here resolves uniquely and correctly).

Either change makes Android native-list replay resolve by the (unique) label and eliminates this class of
false divergence — the single highest-leverage replay-robustness fix for Android.

Secondary (same experiment, lower priority)

A viewport-bottom element (y≈2864 of 2992) intermittently fell outside the captured tree, causing
wait/action-failure divergences — a capture-completeness question worth a separate look.


Found via wave-3 E1 false-divergence experiment. Evidence artifacts (divergence JSONs with targetBinding,
the clean label=-only fixture, per-flow summary.json) available on request.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions