Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ static void appendNode(
// (#2063 empty-fill verification).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
appendAttribute(xml, "hint-showing", Boolean.toString(node.isShowingHintText()));
// The hint itself, whether or not the field is showing it: `text` holds the hint only while
// the field is empty, so a filled field's placeholder is readable nowhere else.
appendNonEmptyAttribute(xml, "hint", node.getHintText());
}
appendAttribute(xml, "editable", Boolean.toString(node.isEditable()));
// Accessibility selection offsets, not a measurement of the value's length. Read-only
Expand Down
5 changes: 5 additions & 0 deletions packages/kernel/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,11 @@ export type RawSnapshotNode = {
editable?: boolean;
password?: boolean;
hintShowing?: boolean;
/**
* Placeholder text of a text field (the Android hint), whether or not the field is showing it.
* Absent when the field has none or the producer did not read it.
*/
placeholder?: string;
/** Accessibility selection offsets, never a character count or proof of value equality. */
selectionStart?: number;
selectionEnd?: number;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { expect, test } from 'vitest';
import { buildUiHierarchySnapshot, parseUiHierarchyTree } from '../ui-hierarchy.ts';

// A form: an empty field shows its hint (and reports the hint as its text), a filled field no longer
// shows it but still has one, and a label has none.
const PLACEHOLDER_XML = `<hierarchy>
<node class="android.widget.FrameLayout" resource-id="root" bounds="[0,0][400,800]"
window-index="0" window-type="1" window-layer="1" window-active="true" window-focused="true"
window-bounds="[0,0][400,800]" visible-to-user="true" enabled="true">
<node class="android.widget.TextView" resource-id="name-label" text="Name"
bounds="[0,0][400,60]" enabled="true" visible-to-user="true" />
<node class="android.widget.EditText" resource-id="name-input" text="Key echo" hint="Key echo"
hint-showing="true" editable="true" bounds="[0,60][400,120]" clickable="true" focusable="true"
enabled="true" visible-to-user="true" />
<node class="android.widget.EditText" resource-id="email-input" text="ada@example.com"
hint="Email address" hint-showing="false" editable="true" bounds="[0,120][400,180]"
clickable="true" focusable="true" enabled="true" visible-to-user="true" />
</node>
</hierarchy>`;

function nodesById(raw: boolean, interactiveOnly = false) {
const { nodes } = buildUiHierarchySnapshot(parseUiHierarchyTree(PLACEHOLDER_XML), undefined, {
raw,
interactiveOnly,
});
return (identifier: string) => nodes.find((node) => node.identifier === identifier);
}

test.each([
{ raw: false, interactiveOnly: false },
{ raw: true, interactiveOnly: false },
{ raw: true, interactiveOnly: true },
])(
'the placeholder reaches snapshot nodes whether or not the field shows it (raw=$raw, -i=$interactiveOnly)',
({ raw, interactiveOnly }) => {
const byId = nodesById(raw, interactiveOnly);
expect(byId('name-input')?.placeholder).toBe('Key echo');
expect(byId('name-input')?.hintShowing).toBe(true);
expect(byId('email-input')?.placeholder).toBe('Email address');
expect(byId('email-input')?.hintShowing).toBe(false);
},
);

test('the value stays what the platform reported: the hint while showing, the text once filled', () => {
const byId = nodesById(false);
expect(byId('name-input')?.value).toBe('Key echo');
expect(byId('email-input')?.value).toBe('ada@example.com');
});

test('a node without a hint carries no placeholder key once serialized', () => {
const byId = nodesById(false);
expect(JSON.parse(JSON.stringify(byId('name-label')))).not.toHaveProperty('placeholder');
});
1 change: 1 addition & 0 deletions packages/platform-android/src/ui-hierarchy-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ function createAndroidRawSnapshotNode(
editable: node.editable,
password: node.password,
hintShowing: node.hintShowing,
placeholder: node.placeholder,
selectionStart: node.selectionStart,
selectionEnd: node.selectionEnd,
visibleToUser: node.visibleToUser,
Expand Down
1 change: 1 addition & 0 deletions packages/platform-android/src/ui-hierarchy-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type AndroidUiHierarchy = {
editable?: boolean;
password?: boolean;
hintShowing?: boolean;
placeholder?: string;
selectionStart?: number;
selectionEnd?: number;
// Two independent facts, never collapsed, and never undefined: the helper omits false attributes
Expand Down
4 changes: 4 additions & 0 deletions packages/platform-android/src/ui-hierarchy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export type AndroidUiNodeMetadata = {
* `getText()` returns the hint on modern Android). Absent in raw uiautomator dumps.
*/
hintShowing?: boolean;
/** Helper-only: the field's hint text (API 26 or later), whether or not it is showing. */
hint?: string;
scrollable?: boolean;
canScrollForward?: boolean;
canScrollBackward?: boolean;
Expand Down Expand Up @@ -168,6 +170,7 @@ function readNodeAttributes(node: string): Omit<AndroidUiNodeMetadata, 'rect'> {
...optionalNumberAttr('selectionStart', 'selection-start'),
...optionalNumberAttr('selectionEnd', 'selection-end'),
...optionalBoolAttr('hintShowing', 'hint-showing'),
...optionalStringAttr('hint', 'hint'),
...optionalBoolAttr('visibleToUser', 'visible-to-user'),
...optionalBoolAttr('selected', 'selected'),
...optionalBoolAttr('heading', 'heading'),
Expand Down Expand Up @@ -331,6 +334,7 @@ function normalizeAndroidUiHierarchyNode(
editable: attrs.editable,
password: attrs.password,
hintShowing: attrs.hintShowing,
placeholder: attrs.hint,
selectionStart: attrs.selectionStart,
selectionEnd: attrs.selectionEnd,
visibleToUser: attrs.visibleToUser,
Expand Down
1 change: 1 addition & 0 deletions src/commands/capture/runtime/snapshot-unchanged.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ test.each<Partial<SnapshotNode>>([
{ enabled: false },
{ selected: true },
{ focused: true },
{ placeholder: 'Key echo' },
{ hittable: false },
{ bundleId: 'com.example.app' },
{ appName: 'Example' },
Expand Down
1 change: 1 addition & 0 deletions src/commands/capture/runtime/snapshot-unchanged.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ const PRESENTATION_SCALAR_FIELDS = {
focused: true,
heading: true,
roleDescription: true,
placeholder: true,
hittable: true,
bundleId: true,
appName: true,
Expand Down
1 change: 1 addition & 0 deletions src/daemon/__tests__/response-views.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ test('attrs digest keeps explicit false/zero/empty field facts; unavailable ones
editable: false,
password: false,
hintShowing: false,
placeholder: 'Key echo',
selectionStart: 0,
selectionEnd: 0,
};
Expand Down
1 change: 1 addition & 0 deletions src/daemon/response-views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ const SELECTOR_DIGEST_NODE_FIELDS = [
'editable',
'password',
'hintShowing',
'placeholder',
'selectionStart',
'selectionEnd',
'hittable',
Expand Down
11 changes: 7 additions & 4 deletions website/docs/docs/snapshots.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,10 @@ the strategy owns which tiers it may use.
## Android node metadata

Android snapshot nodes and `get attrs` (including the digest response) carry the native
`selected`, `heading`, `roleDescription`, `editable`, `password`, `hintShowing`, `selectionStart`,
and `selectionEnd` facts whenever the accessibility tree reports them. Explicit `false` and `0` are
kept; an absent field means the fact was unavailable, not false. `hintShowing` needs Android API 26
or later, `heading` API 28 or later.
`selected`, `heading`, `roleDescription`, `editable`, `password`, `hintShowing`, `placeholder`,
`selectionStart`, and `selectionEnd` facts whenever the accessibility tree reports them. Explicit
`false` and `0` are kept; an absent field means the fact was unavailable, not false. `hintShowing`
and `placeholder` need Android API 26 or later, `heading` API 28 or later.

- `selected` is the accessibility selected state an app sets on a control — the active bottom-tab or
segmented-control item, or the chosen row of a list. Android reports it explicitly as `true` or
Expand All @@ -135,6 +135,9 @@ or later, `heading` API 28 or later.
- `value: ""` is an explicitly empty accessibility text; a missing `value` means no text was
reported. The text of an empty field is its hint on modern Android, so check `hintShowing`
before reading `value` as the entered contents.
- `placeholder` is the field's hint text itself, present whether the field is empty or filled: an
empty field shows it (`hintShowing: true`, and `value` repeats it), a filled field no longer does.
A field without a hint omits it.
- `selectionStart`/`selectionEnd` are accessibility selection offsets. They are independent of
`editable` (read-only selectable text exposes them too), they are not a character count, and
they do not prove that a masked or secure value equals expected text.
Loading