Skip to content

Commit efce352

Browse files
authored
fix(android): carry the heading flag and the role description on the snapshot node (#2915)
React Native puts a header, a tab, a tab list, a radio group, a link, or a menu on a plain android.view.View and tells the accessibility tree what it is through two facts: the heading flag and the role description androidx stores in the node extras. The helper serialized neither, and the host reads only the helper's XML, so every one of them was a nameless View to an agent and to `get attrs`. The helper now writes `heading` when the node reports it (API 28 or later) and `role-description` when the app set one; the parser, the Android hierarchy node, and the published snapshot node carry them to `get attrs`, the unchanged-snapshot comparison, and the selector digest. The class stays the type, and snapshot text is unchanged: mapping a description to a role is the consumer's call.
1 parent 86d719d commit efce352

11 files changed

Lines changed: 156 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
## Unreleased
44

5+
- Fixed (android): snapshot nodes and `get attrs` carry the accessibility `heading` flag and the
6+
`roleDescription` an app set on a node. React Native puts a header, a tab, a tab list, a link, or a
7+
menu on a plain `android.view.View` and tells the accessibility tree what it is through these two
8+
facts; the helper never serialized either, so every one of them was a nameless `View` to an agent.
9+
The helper now writes `heading` when the node reports it (API 28 or later) and `role-description`
10+
when the app set one, and the parser, the Android hierarchy node, and the published snapshot node
11+
carry them to `get attrs` and the selector digest. The class stays the `type`.
512
- Fixed (ios): `perf cpu profile report --kind xctrace` on Xcode 27 no longer fails with
613
`Apple xctrace CPU report contained no samples` on a trace that holds thousands of samples. Xcode
714
27 exports each `time-profile` sample stack as `<tagged-backtrace>` instead of `<backtrace>`, and

‎android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityTreeXml.java‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ static void appendNode(
5353
appendAttribute(xml, "class", node.getClassName());
5454
appendNonEmptyAttribute(xml, "package", node.getPackageName());
5555
appendNonEmptyAttribute(xml, "content-desc", node.getContentDescription());
56+
appendNonEmptyAttribute(xml, "role-description", roleDescription(node));
57+
appendTrueAttribute(xml, "heading", isHeading(node));
5658
appendAttribute(xml, "visible-to-user", Boolean.toString(node.isVisibleToUser()));
5759
appendDrawingOrderAttribute(xml, node);
5860
appendTrueAttribute(xml, "clickable", node.isClickable());
@@ -146,6 +148,19 @@ private static void appendTrueAttribute(StringBuilder xml, String name, boolean
146148
}
147149
}
148150

151+
// The platform node has no role description getter: androidx writes the value an app set
152+
// (AccessibilityNodeInfoCompat.setRoleDescription) into the node extras under this key, and
153+
// TalkBack reads it from there.
154+
private static CharSequence roleDescription(AccessibilityNodeInfo node) {
155+
return node.getExtras().getCharSequence("AccessibilityNodeInfo.roleDescription");
156+
}
157+
158+
// isHeading() arrived in API 28. Older releases keep the compat flag in an extras bit this
159+
// helper does not read, so a heading on API 23-27 reports nothing.
160+
private static boolean isHeading(AccessibilityNodeInfo node) {
161+
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && node.isHeading();
162+
}
163+
149164
// Declared residue (agent-device #1832): checked / checkable / long-clickable are not serialized,
150165
// so toggle state is invisible to agents. Adding them is a helper protocol change (new attributes
151166
// + host parser + fields on the wire node), tracked there.

‎packages/kernel/src/snapshot.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,10 @@ export type RawSnapshotNode = {
238238
enabled?: boolean;
239239
selected?: boolean;
240240
focused?: boolean;
241+
/** Accessibility heading flag an app set on the node; absent means not a heading or unavailable. */
242+
heading?: boolean;
243+
/** Localized role description an app set beside the native class, verbatim (`Tab`, `Tab List`, `Link`). */
244+
roleDescription?: string;
241245
/** Native accessibility facts; absent means unavailable, not false. */
242246
editable?: boolean;
243247
password?: boolean;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { expect, test } from 'vitest';
2+
import { buildUiHierarchySnapshot, parseUiHierarchyTree } from '../ui-hierarchy.ts';
3+
4+
// A React Native screen: `accessibilityRole="header"` is a plain View the helper flags as a heading,
5+
// a tab bar is a View with the role description the app set, and a label carries neither.
6+
const ROLE_FACTS_XML = `<hierarchy>
7+
<node class="android.widget.FrameLayout" resource-id="root" bounds="[0,0][400,800]"
8+
window-index="0" window-type="1" window-layer="1" window-active="true" window-focused="true"
9+
window-bounds="[0,0][400,800]" visible-to-user="true" enabled="true">
10+
<node class="android.view.View" resource-id="inventory-header" text="Inventory" heading="true"
11+
bounds="[0,0][400,60]" enabled="true" visible-to-user="true" />
12+
<node class="android.view.View" resource-id="section-tabs" role-description="tab list"
13+
bounds="[0,60][400,120]" enabled="true" visible-to-user="true">
14+
<node class="android.view.View" resource-id="tab-fields" text="Fields" role-description="tab"
15+
bounds="[0,60][200,120]" clickable="true" enabled="true" visible-to-user="true" />
16+
</node>
17+
<node class="android.widget.TextView" resource-id="plain-label" text="Wi-Fi"
18+
bounds="[0,120][400,180]" enabled="true" visible-to-user="true" />
19+
</node>
20+
</hierarchy>`;
21+
22+
function nodesById(raw: boolean, interactiveOnly = false) {
23+
const { nodes } = buildUiHierarchySnapshot(parseUiHierarchyTree(ROLE_FACTS_XML), undefined, {
24+
raw,
25+
interactiveOnly,
26+
});
27+
return (identifier: string) => nodes.find((node) => node.identifier === identifier);
28+
}
29+
30+
test.each([
31+
{ raw: false, interactiveOnly: false },
32+
{ raw: true, interactiveOnly: false },
33+
{ raw: true, interactiveOnly: true },
34+
])(
35+
'the heading flag and the role description reach snapshot nodes (raw=$raw, -i=$interactiveOnly)',
36+
({ raw, interactiveOnly }) => {
37+
const byId = nodesById(raw, interactiveOnly);
38+
expect(byId('inventory-header')?.heading).toBe(true);
39+
expect(byId('section-tabs')?.roleDescription).toBe('tab list');
40+
expect(byId('tab-fields')?.roleDescription).toBe('tab');
41+
},
42+
);
43+
44+
test('a node without either fact carries neither key once serialized', () => {
45+
const byId = nodesById(false);
46+
const label = JSON.parse(JSON.stringify(byId('plain-label')));
47+
expect(label).not.toHaveProperty('heading');
48+
expect(label).not.toHaveProperty('roleDescription');
49+
// A heading is not a role description and a role description is not a heading.
50+
expect(JSON.parse(JSON.stringify(byId('inventory-header')))).not.toHaveProperty(
51+
'roleDescription',
52+
);
53+
expect(JSON.parse(JSON.stringify(byId('tab-fields')))).not.toHaveProperty('heading');
54+
});
55+
56+
test('the class stays the type: a role description refines nothing on its own', () => {
57+
const byId = nodesById(false);
58+
expect(byId('tab-fields')?.type).toBe('android.view.View');
59+
expect(byId('inventory-header')?.label).toBe('Inventory');
60+
});

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,8 @@ function createAndroidRawSnapshotNode(
354354
enabled: node.enabled,
355355
focused: node.focused,
356356
selected: node.selected,
357+
heading: node.heading,
358+
roleDescription: node.roleDescription,
357359
editable: node.editable,
358360
password: node.password,
359361
hintShowing: node.hintShowing,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export type AndroidUiHierarchy = {
1717
visibleToUser?: boolean;
1818
focused?: boolean;
1919
selected?: boolean;
20+
heading?: boolean;
21+
roleDescription?: string;
2022
editable?: boolean;
2123
password?: boolean;
2224
hintShowing?: boolean;

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ export type AndroidUiNodeMetadata = {
3535
focusable?: boolean;
3636
focused?: boolean;
3737
selected?: boolean;
38+
/** Helper-only: the accessibility heading flag an app set on the node (API 28 or later). */
39+
heading?: boolean;
40+
/** Helper-only: the localized role description an app set beside the class, verbatim. */
41+
roleDescription?: string;
3842
password?: boolean;
3943
editable?: boolean;
4044
selectionStart?: number;
@@ -132,6 +136,15 @@ function readNodeAttributes(node: string): Omit<AndroidUiNodeMetadata, 'rect'> {
132136
const value = parseBounds(getAttr(name));
133137
return value === undefined ? {} : ({ [key]: value } as Pick<AndroidUiNodeMetadata, Key>);
134138
};
139+
const optionalStringAttr = <Key extends keyof AndroidUiNodeMetadata>(
140+
key: Key,
141+
name: string,
142+
): Partial<Pick<AndroidUiNodeMetadata, Key>> => {
143+
const value = getAttr(name);
144+
return value === null || value === ''
145+
? {}
146+
: ({ [key]: value } as Pick<AndroidUiNodeMetadata, Key>);
147+
};
135148
const optionalBoolAttr = <Key extends keyof AndroidUiNodeMetadata>(
136149
key: Key,
137150
name: string,
@@ -157,6 +170,8 @@ function readNodeAttributes(node: string): Omit<AndroidUiNodeMetadata, 'rect'> {
157170
...optionalBoolAttr('hintShowing', 'hint-showing'),
158171
...optionalBoolAttr('visibleToUser', 'visible-to-user'),
159172
...optionalBoolAttr('selected', 'selected'),
173+
...optionalBoolAttr('heading', 'heading'),
174+
...optionalStringAttr('roleDescription', 'role-description'),
160175
...optionalNumberAttr('drawingOrder', 'drawing-order'),
161176
...optionalBoolAttr('scrollable', 'scrollable'),
162177
...optionalBoolAttr('canScrollForward', 'can-scroll-forward'),
@@ -311,6 +326,8 @@ function normalizeAndroidUiHierarchyNode(
311326
enabled: attrs.enabled,
312327
focused: attrs.focused,
313328
selected: attrs.selected,
329+
heading: attrs.heading,
330+
roleDescription: attrs.roleDescription,
314331
editable: attrs.editable,
315332
password: attrs.password,
316333
hintShowing: attrs.hintShowing,

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,42 @@ test('a published Android snapshot answers a selected-qualified read (#2462)', a
236236
);
237237
});
238238

239+
// A React Native screen: the header role is a View flagged as a heading, the tab bar and its tabs
240+
// are Views with the role description the app set, and the helper writes neither on a plain label.
241+
const ANDROID_ROLE_FACTS_XML = `<hierarchy>
242+
<node class="android.widget.FrameLayout" resource-id="com.example.app:id/root" bounds="[0,0][390,300]" enabled="true" visible-to-user="true">
243+
<node class="android.view.View" resource-id="com.example.app:id/header" text="Inventory" heading="true" bounds="[0,0][390,60]" enabled="true" visible-to-user="true"/>
244+
<node class="android.view.View" resource-id="com.example.app:id/tabs" role-description="tab list" bounds="[0,60][390,120]" enabled="true" visible-to-user="true">
245+
<node class="android.view.View" resource-id="com.example.app:id/tab-fields" text="Fields" role-description="tab" bounds="[0,60][195,120]" clickable="true" enabled="true" visible-to-user="true"/>
246+
</node>
247+
<node class="android.widget.TextView" resource-id="com.example.app:id/label" text="Wi-Fi" bounds="[0,120][390,180]" enabled="true" visible-to-user="true"/>
248+
</node>
249+
</hierarchy>`;
250+
251+
test('a published Android snapshot carries the heading flag and the role description', () => {
252+
const nodes = publishUiHierarchy(ANDROID_ROLE_FACTS_XML).nodes;
253+
const byId = (identifier: string) => nodes.find((node) => node.identifier === identifier)!;
254+
255+
assert.equal(byId('com.example.app:id/header').heading, true);
256+
assert.equal(byId('com.example.app:id/tabs').roleDescription, 'tab list');
257+
assert.equal(byId('com.example.app:id/tab-fields').roleDescription, 'tab');
258+
assert.equal(byId('com.example.app:id/label').heading, undefined);
259+
assert.equal(byId('com.example.app:id/label').roleDescription, undefined);
260+
assert.deepEqual(
261+
Array.from(androidUiNodes(ANDROID_ROLE_FACTS_XML)).map((node) => [
262+
node.heading,
263+
node.roleDescription,
264+
]),
265+
[
266+
[undefined, undefined],
267+
[true, undefined],
268+
[undefined, 'tab list'],
269+
[undefined, 'tab'],
270+
[undefined, undefined],
271+
],
272+
);
273+
});
274+
239275
test('parseUiHierarchy discards stale inactive Android application windows', () => {
240276
const xml = `<hierarchy>
241277
<node class="android.widget.FrameLayout" package="com.example.app" bounds="[0,0][390,844]" window-index="0" window-type="1" window-layer="10" window-active="true" window-focused="true" window-bounds="[0,0][390,844]">

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ const PRESENTATION_SCALAR_FIELDS = {
112112
enabled: true,
113113
selected: true,
114114
focused: true,
115+
heading: true,
116+
roleDescription: true,
115117
hittable: true,
116118
bundleId: true,
117119
appName: true,

‎src/daemon/response-views.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ const SELECTOR_DIGEST_NODE_FIELDS = [
115115
'enabled',
116116
'selected',
117117
'focused',
118+
'heading',
119+
'roleDescription',
118120
'editable',
119121
'password',
120122
'hintShowing',

0 commit comments

Comments
 (0)