Skip to content

Commit d5c21f8

Browse files
authored
fix(android): carry the content description beside the label on the snapshot node (#2912)
An Android node is labelled by its text and falls back to the content description only when it has none, so an accessibility label the app set beside visible text never reached the wire: a labelled text view was named by its content, a filled field by its value, an empty field by its hint, a filled password field by its dots. The parser already read `content-desc`; the hierarchy node now carries it as `contentDescription` whenever it is not already the label, through the raw snapshot node, the unchanged-snapshot comparison, and the selector digest. `label` is unchanged, so nothing that keys on it moves.
1 parent dbc08a4 commit d5c21f8

7 files changed

Lines changed: 37 additions & 1 deletion

File tree

‎packages/kernel/src/snapshot.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,13 @@ export type RawSnapshotNode = {
226226
subrole?: string;
227227
label?: string;
228228
value?: string;
229+
/**
230+
* Android content description when it is not already the `label`. An Android node is
231+
* labelled by its text and falls back to the content description only when it has none,
232+
* so an accessibility label the app set beside visible text (a labelled text view, a
233+
* filled or hinted field) is carried here for consumers that want the accessible name.
234+
*/
235+
contentDescription?: string;
229236
identifier?: string;
230237
rect?: Rect;
231238
enabled?: boolean;

‎packages/platform-android/src/ui-hierarchy-builder.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ function createAndroidRawSnapshotNode(
347347
type: node.type ?? undefined,
348348
label: node.label ?? undefined,
349349
value: node.value ?? undefined,
350+
contentDescription: node.contentDescription,
350351
identifier: node.identifier ?? undefined,
351352
bundleId: node.packageName ?? undefined,
352353
rect: node.rect,

‎packages/platform-android/src/ui-hierarchy-node.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export type AndroidUiHierarchy = {
88
type: string | null;
99
label: string | null;
1010
value: string | null;
11+
/** The content description when the label is the text rather than it; see `RawSnapshotNode`. */
12+
contentDescription?: string;
1113
identifier: string | null;
1214
packageName: string | null;
1315
rect?: Rect;

‎packages/platform-android/src/ui-hierarchy.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,11 +298,13 @@ function normalizeAndroidUiHierarchyNode(
298298
depth: number,
299299
parent: AndroidNode,
300300
): AndroidUiHierarchy {
301+
const label = attrs.text || attrs.desc;
301302
return attachAndroidSiblingOrder(
302303
{
303304
type: attrs.className,
304-
label: attrs.text || attrs.desc,
305+
label,
305306
value: attrs.text,
307+
...(attrs.desc && attrs.desc !== label ? { contentDescription: attrs.desc } : {}),
306308
identifier: attrs.resourceId,
307309
packageName: attrs.packageName,
308310
rect: attrs.rect,

‎src/__tests__/android-ui-hierarchy.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ test('parseUiHierarchy reads double-quoted Android node attributes', () => {
4141
assert.equal(result.nodes.length, 1);
4242
assert.equal(result.nodes[0]!.value, 'Hello');
4343
assert.equal(result.nodes[0]!.label, 'Hello');
44+
assert.equal(result.nodes[0]!.contentDescription, 'Greeting');
4445
assert.equal(result.nodes[0]!.identifier, 'com.demo:id/title');
4546
assert.deepEqual(result.nodes[0]!.rect, { x: 10, y: 20, width: 100, height: 40 });
4647
assert.equal(result.nodes[0]!.hittable, true);
@@ -111,9 +112,30 @@ test('interactive Android snapshots keep Compose semantic views inside actionabl
111112

112113
assert.equal(result.nodes[0]?.hittable, true);
113114
assert.equal(result.nodes[1]?.label, 'Start a call');
115+
assert.equal(result.nodes[1]?.contentDescription, undefined);
114116
assert.equal(result.nodes[1]?.parentIndex, 0);
115117
});
116118

119+
test('an Android content description beside text is carried, and one that is the label is not repeated', () => {
120+
const xml = `<hierarchy>
121+
<node class="android.widget.EditText" package="com.example.app" text="Ada Lovelace" content-desc="Name field" resource-id="name-input" bounds="[0,0][390,44]" visible-to-user="true"/>
122+
<node class="android.widget.EditText" package="com.example.app" text="Type your name" content-desc="Name field" hint-showing="true" resource-id="empty-input" bounds="[0,60][390,104]" visible-to-user="true"/>
123+
<node class="android.widget.TextView" package="com.example.app" text="Plain controls" content-desc="Plain controls" bounds="[0,120][390,164]" visible-to-user="true"/>
124+
<node class="android.widget.TextView" package="com.example.app" text="Untitled" bounds="[0,180][390,224]" visible-to-user="true"/>
125+
</hierarchy>`;
126+
127+
const result = parseUiHierarchy(xml, 800, { raw: true });
128+
129+
assert.equal(result.nodes[0]?.label, 'Ada Lovelace');
130+
assert.equal(result.nodes[0]?.contentDescription, 'Name field');
131+
assert.equal(result.nodes[1]?.label, 'Type your name');
132+
assert.equal(result.nodes[1]?.hintShowing, true);
133+
assert.equal(result.nodes[1]?.contentDescription, 'Name field');
134+
assert.equal(result.nodes[2]?.label, 'Plain controls');
135+
assert.equal(result.nodes[2]?.contentDescription, undefined);
136+
assert.equal(result.nodes[3]?.contentDescription, undefined);
137+
});
138+
117139
test('interactive Android snapshots keep a fixed sibling outside filtered scroll content (#1377)', () => {
118140
const xml = `<hierarchy>
119141
<node class="android.widget.FrameLayout" bounds="[0,0][400,800]" visible-to-user="true">

‎src/commands/capture/runtime/snapshot-unchanged.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ const PRESENTATION_SCALAR_FIELDS = {
107107
subrole: true,
108108
label: true,
109109
value: true,
110+
contentDescription: true,
110111
identifier: true,
111112
enabled: true,
112113
selected: true,

‎src/daemon/response-views.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ const SELECTOR_DIGEST_NODE_FIELDS = [
110110
'subrole',
111111
'label',
112112
'value',
113+
'contentDescription',
113114
'identifier',
114115
'enabled',
115116
'selected',

0 commit comments

Comments
 (0)