Skip to content

Commit 54c5f8f

Browse files
committed
perf: reduce android snapshot helper overhead
1 parent 50242fd commit 54c5f8f

6 files changed

Lines changed: 165 additions & 71 deletions

File tree

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

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -335,29 +335,30 @@ private static void appendNode(
335335
node.getBoundsInScreen(bounds);
336336
xml.append("<node");
337337
appendAttribute(xml, "index", Integer.toString(nodeIndex));
338-
appendAttribute(xml, "text", node.getText());
339-
appendAttribute(xml, "resource-id", node.getViewIdResourceName());
338+
appendNonEmptyAttribute(xml, "text", node.getText());
339+
appendNonEmptyAttribute(xml, "resource-id", node.getViewIdResourceName());
340340
appendAttribute(xml, "class", node.getClassName());
341-
appendAttribute(xml, "package", node.getPackageName());
342-
appendAttribute(xml, "content-desc", node.getContentDescription());
343-
appendAttribute(xml, "checkable", Boolean.toString(node.isCheckable()));
344-
appendAttribute(xml, "checked", Boolean.toString(node.isChecked()));
345-
appendAttribute(xml, "clickable", Boolean.toString(node.isClickable()));
341+
appendNonEmptyAttribute(xml, "package", node.getPackageName());
342+
appendNonEmptyAttribute(xml, "content-desc", node.getContentDescription());
343+
appendTrueAttribute(xml, "clickable", node.isClickable());
346344
appendAttribute(xml, "enabled", Boolean.toString(node.isEnabled()));
347-
appendAttribute(xml, "focusable", Boolean.toString(node.isFocusable()));
348-
appendAttribute(xml, "focused", Boolean.toString(node.isFocused()));
349-
appendAttribute(xml, "scrollable", Boolean.toString(node.isScrollable()));
350-
appendAttribute(
351-
xml,
352-
"can-scroll-forward",
353-
Boolean.toString(hasAccessibilityAction(node, AccessibilityAction.ACTION_SCROLL_FORWARD)));
354-
appendAttribute(
355-
xml,
356-
"can-scroll-backward",
357-
Boolean.toString(hasAccessibilityAction(node, AccessibilityAction.ACTION_SCROLL_BACKWARD)));
358-
appendAttribute(xml, "long-clickable", Boolean.toString(node.isLongClickable()));
359-
appendAttribute(xml, "password", Boolean.toString(node.isPassword()));
360-
appendAttribute(xml, "selected", Boolean.toString(node.isSelected()));
345+
appendTrueAttribute(xml, "focusable", node.isFocusable());
346+
appendTrueAttribute(xml, "focused", node.isFocused());
347+
boolean scrollable = node.isScrollable();
348+
if (scrollable) {
349+
appendAttribute(xml, "scrollable", "true");
350+
appendAttribute(
351+
xml,
352+
"can-scroll-forward",
353+
Boolean.toString(
354+
hasAccessibilityAction(node, AccessibilityAction.ACTION_SCROLL_FORWARD)));
355+
appendAttribute(
356+
xml,
357+
"can-scroll-backward",
358+
Boolean.toString(
359+
hasAccessibilityAction(node, AccessibilityAction.ACTION_SCROLL_BACKWARD)));
360+
}
361+
appendTrueAttribute(xml, "password", node.isPassword());
361362
appendAttribute(
362363
xml,
363364
"bounds",
@@ -397,6 +398,19 @@ private static void appendNode(
397398
xml.append("</node>");
398399
}
399400

401+
private static void appendNonEmptyAttribute(StringBuilder xml, String name, CharSequence value) {
402+
if (value == null || value.length() == 0) {
403+
return;
404+
}
405+
appendAttribute(xml, name, value);
406+
}
407+
408+
private static void appendTrueAttribute(StringBuilder xml, String name, boolean value) {
409+
if (value) {
410+
appendAttribute(xml, name, "true");
411+
}
412+
}
413+
400414
private static void appendAttribute(StringBuilder xml, String name, CharSequence value) {
401415
String stringValue = value == null ? "" : value.toString();
402416
xml.append(' ');

src/platforms/android/__tests__/snapshot-helper.test.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -594,17 +594,14 @@ test('captureAndroidSnapshotWithHelper can read output file when chunks are disa
594594
const outputPath = '/sdcard/Download/agent-device-snapshot.xml';
595595
const adb: AndroidAdbExecutor = async (args) => {
596596
adbCalls.push(args);
597-
if (args[0] === 'shell' && args[1] === 'cat') {
598-
assert.equal(args[2], outputPath);
597+
if (args[0] === 'shell' && args[1] === 'sh') {
598+
assert.equal(args.at(-1), outputPath);
599599
return {
600600
exitCode: 0,
601601
stdout: '<hierarchy><node index="0" /></hierarchy>',
602602
stderr: '',
603603
};
604604
}
605-
if (args[0] === 'shell' && args[1] === 'rm') {
606-
return { exitCode: 0, stdout: '', stderr: '' };
607-
}
608605
return {
609606
exitCode: 0,
610607
stdout: helperOutput({
@@ -662,6 +659,14 @@ test('captureAndroidSnapshotWithHelper can read output file when chunks are disa
662659
'false',
663660
'com.callstack.agentdevice.snapshothelper/.SnapshotInstrumentation',
664661
]);
662+
assert.deepEqual(adbCalls[1], [
663+
'shell',
664+
'sh',
665+
'-c',
666+
'cat "$1"; status=$?; rm -f "$1"; exit "$status"',
667+
'agent-device-snapshot-helper-output',
668+
outputPath,
669+
]);
665670
assert.equal(result.xml, '<hierarchy><node index="0" /></hierarchy>');
666671
assert.equal(result.metadata.maxNodes, 100);
667672
});
@@ -730,16 +735,13 @@ test('captureAndroidSnapshotWithHelper reads helper output file when instrumenta
730735
stderr: '',
731736
};
732737
}
733-
if (args[0] === 'shell' && args[1] === 'cat') {
738+
if (args[0] === 'shell' && args[1] === 'sh') {
734739
return {
735740
exitCode: 0,
736741
stdout: '<hierarchy><node text="file fallback"/></hierarchy>',
737742
stderr: '',
738743
};
739744
}
740-
if (args[0] === 'shell' && args[1] === 'rm') {
741-
return { exitCode: 0, stdout: '', stderr: '' };
742-
}
743745
throw new Error(`unexpected args: ${args.join(' ')}`);
744746
},
745747
outputPath: '/sdcard/Android/data/com.callstack.agentdevice.snapshothelper/files/test.xml',
@@ -749,7 +751,10 @@ test('captureAndroidSnapshotWithHelper reads helper output file when instrumenta
749751
assert.equal(result.metadata.outputFormat, 'uiautomator-xml');
750752
assert.deepEqual(calls.at(1), [
751753
'shell',
752-
'cat',
754+
'sh',
755+
'-c',
756+
'cat "$1"; status=$?; rm -f "$1"; exit "$status"',
757+
'agent-device-snapshot-helper-output',
753758
'/sdcard/Android/data/com.callstack.agentdevice.snapshothelper/files/test.xml',
754759
]);
755760
});

src/platforms/android/__tests__/snapshot.test.ts

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ test('snapshotAndroid uses injected helper artifact before stock uiautomator', a
338338
assert.equal(result.androidSnapshot.installReason, 'current');
339339
assert.equal(result.androidSnapshot.captureMode, 'interactive-windows');
340340
assert.equal(result.androidSnapshot.windowCount, 1);
341-
assert.deepEqual(timeouts, [30000, 30000, 5000]);
341+
assert.deepEqual(timeouts, [30000, 30000]);
342342
assert.equal(mockRunCmd.mock.calls.length, 0);
343343
});
344344

@@ -367,10 +367,8 @@ test('snapshotAndroid forwards alert-style helper idle timeout override', async
367367

368368
assert.ok(instrumentArgs);
369369
assert.equal(instrumentArgs[instrumentArgs.indexOf('waitForIdleTimeoutMs') + 1], '0');
370-
assert.match(
371-
instrumentArgs[instrumentArgs.indexOf('outputPath') + 1] ?? '',
372-
/^\/sdcard\/Download\/agent-device-snapshot-/,
373-
);
370+
assert.equal(instrumentArgs.includes('outputPath'), false);
371+
assert.equal(instrumentArgs.includes('emitChunks'), false);
374372
});
375373

376374
test('snapshotAndroid emits helper phase diagnostics', async () => {
@@ -452,7 +450,7 @@ test('snapshotAndroid resolves helper adb through scoped provider', async () =>
452450
assert.equal(result.androidSnapshot.backend, 'android-helper');
453451
assert.deepEqual(
454452
adbCalls.map((args) => args[0]),
455-
['shell', 'shell', 'shell'],
453+
['shell', 'shell'],
456454
);
457455
assert.equal(mockRunCmd.mock.calls.length, 0);
458456
});
@@ -1140,6 +1138,39 @@ test('snapshotAndroid derives hidden content hints for interactive snapshots fro
11401138
assert.equal(scrollArea?.hiddenContentBelow, true);
11411139
});
11421140

1141+
test('snapshotAndroid omits zero-area interactive nodes from interactive snapshots', async () => {
1142+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
1143+
<hierarchy rotation="0">
1144+
<node class="android.widget.FrameLayout" bounds="[0,0][390,844]" clickable="false" focusable="false">
1145+
<node class="android.widget.ScrollView" scrollable="true" can-scroll-forward="true" can-scroll-backward="false" bounds="[0,100][390,600]" clickable="false" focusable="false">
1146+
<node class="android.view.ViewGroup" bounds="[0,100][390,600]" clickable="false" focusable="false">
1147+
<node class="android.widget.Button" text="Visible action" bounds="[20,120][200,180]" clickable="true" focusable="true" />
1148+
<node class="android.widget.Button" text="Collapsed action" bounds="[20,844][200,844]" clickable="true" focusable="true" />
1149+
</node>
1150+
</node>
1151+
</node>
1152+
</hierarchy>`;
1153+
1154+
mockAndroidSnapshotXml(xml);
1155+
1156+
const result = await snapshotAndroid(device, { interactiveOnly: true });
1157+
1158+
assert.equal(
1159+
result.nodes.some((node) => node.label === 'Visible action'),
1160+
true,
1161+
);
1162+
assert.equal(
1163+
result.nodes.some((node) => node.label === 'Collapsed action'),
1164+
false,
1165+
);
1166+
assert.equal(
1167+
result.nodes.some(
1168+
(node) => node.rect !== undefined && (node.rect.width <= 0 || node.rect.height <= 0),
1169+
),
1170+
false,
1171+
);
1172+
});
1173+
11431174
test('snapshotAndroid preserves bottomed-out hidden-above hints in interactive snapshots from a single aligned block', async () => {
11441175
const xml = `<?xml version="1.0" encoding="UTF-8"?>
11451176
<hierarchy rotation="0">

src/platforms/android/snapshot-helper-capture.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ type AndroidSnapshotHelperResolvedCaptureOptions = {
4343
emitChunks?: boolean;
4444
};
4545

46+
type AndroidSnapshotHelperReadResult = {
47+
output: AndroidSnapshotHelperOutput;
48+
cleanupDone: boolean;
49+
};
50+
4651
export async function captureAndroidSnapshotWithHelper(
4752
options: AndroidSnapshotHelperCaptureOptions,
4853
): Promise<AndroidSnapshotHelperOutput> {
@@ -51,8 +56,10 @@ export async function captureAndroidSnapshotWithHelper(
5156
allowFailure: true,
5257
timeoutMs: resolved.commandTimeoutMs,
5358
});
54-
const output = await readAndroidSnapshotHelperOutput(options, resolved, result);
55-
if (resolved.outputPath) await removeHelperOutputFile(options.adb, resolved.outputPath);
59+
const { output, cleanupDone } = await readAndroidSnapshotHelperOutput(options, resolved, result);
60+
if (resolved.outputPath && !cleanupDone) {
61+
await removeHelperOutputFile(options.adb, resolved.outputPath);
62+
}
5663
if (result.exitCode !== 0) {
5764
throw new AppError('COMMAND_FAILED', 'Android snapshot helper failed', {
5865
stdout: result.stdout,
@@ -129,10 +136,13 @@ async function readAndroidSnapshotHelperOutput(
129136
options: AndroidSnapshotHelperCaptureOptions,
130137
resolved: AndroidSnapshotHelperResolvedCaptureOptions,
131138
result: Awaited<ReturnType<AndroidSnapshotHelperCaptureOptions['adb']>>,
132-
): Promise<AndroidSnapshotHelperOutput> {
139+
): Promise<AndroidSnapshotHelperReadResult> {
133140
try {
134141
// The helper can report structured ok=false details even when am exits non-zero.
135-
return parseAndroidSnapshotHelperOutput(`${result.stdout}\n${result.stderr}`);
142+
return {
143+
output: parseAndroidSnapshotHelperOutput(`${result.stdout}\n${result.stderr}`),
144+
cleanupDone: false,
145+
};
136146
} catch (error) {
137147
return await readFallbackHelperOutputOrThrow(options, resolved, result, error);
138148
}
@@ -143,10 +153,10 @@ async function readFallbackHelperOutputOrThrow(
143153
resolved: AndroidSnapshotHelperResolvedCaptureOptions,
144154
result: Awaited<ReturnType<AndroidSnapshotHelperCaptureOptions['adb']>>,
145155
error: unknown,
146-
): Promise<AndroidSnapshotHelperOutput> {
156+
): Promise<AndroidSnapshotHelperReadResult> {
147157
if (error instanceof AppError && result.exitCode !== 0 && error.details?.helper) throw error;
148158
const fileOutput = await readFallbackHelperOutputFile(options, resolved, result);
149-
if (fileOutput) return fileOutput;
159+
if (fileOutput) return { output: fileOutput, cleanupDone: true };
150160
throw new AppError(
151161
'COMMAND_FAILED',
152162
result.exitCode === 0
@@ -195,14 +205,12 @@ async function readHelperOutputFile(
195205
): Promise<AndroidSnapshotHelperOutput | undefined> {
196206
let result: Awaited<ReturnType<AndroidSnapshotHelperCaptureOptions['adb']>>;
197207
try {
198-
result = await adb(['shell', 'cat', outputPath], {
208+
result = await adb(buildReadAndRemoveHelperOutputArgs(outputPath), {
199209
allowFailure: true,
200210
timeoutMs: 5_000,
201211
});
202212
} catch {
203213
return undefined;
204-
} finally {
205-
await removeHelperOutputFile(adb, outputPath);
206214
}
207215
if (result.exitCode !== 0) return undefined;
208216
const xml = result.stdout.trim();
@@ -213,6 +221,17 @@ async function readHelperOutputFile(
213221
};
214222
}
215223

224+
function buildReadAndRemoveHelperOutputArgs(outputPath: string): string[] {
225+
return [
226+
'shell',
227+
'sh',
228+
'-c',
229+
'cat "$1"; status=$?; rm -f "$1"; exit "$status"',
230+
'agent-device-snapshot-helper-output',
231+
outputPath,
232+
];
233+
}
234+
216235
function readHelperMetadataFromInstrumentationOutput(
217236
output: string,
218237
): AndroidSnapshotHelperMetadata | null {

src/platforms/android/snapshot.ts

Lines changed: 45 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -85,34 +85,61 @@ export async function snapshotAndroid(
8585
}
8686

8787
const tree = parseUiHierarchyTree(xml);
88-
const fullSnapshot = buildUiHierarchySnapshot(tree, ANDROID_SNAPSHOT_MAX_NODES, {
89-
...options,
90-
interactiveOnly: false,
91-
});
9288
const interactiveSnapshot = buildUiHierarchySnapshot(tree, ANDROID_SNAPSHOT_MAX_NODES, options);
9389
if (includeHiddenContentHints) {
94-
const nativeHints = await deriveScrollableContentHintsIfNeeded(
90+
await applyHiddenContentHintsToInteractiveSnapshot({
9591
device,
96-
fullSnapshot.nodes,
92+
options,
93+
tree,
9794
xml,
9895
adb,
99-
);
100-
applyHiddenContentHintsToInteractiveNodes(nativeHints, fullSnapshot, interactiveSnapshot);
101-
if (nativeHints.size === 0) {
102-
const presentationHints = deriveMobileSnapshotHiddenContentHints(
103-
attachRefs(fullSnapshot.nodes),
104-
);
105-
applyHiddenContentHintsToInteractiveNodes(
106-
presentationHints,
107-
fullSnapshot,
108-
interactiveSnapshot,
109-
);
110-
}
96+
interactiveSnapshot,
97+
});
11198
}
11299
const { sourceNodes: _sourceNodes, ...snapshot } = interactiveSnapshot;
113100
return { ...snapshot, androidSnapshot: capture.metadata };
114101
}
115102

103+
async function applyHiddenContentHintsToInteractiveSnapshot(params: {
104+
device: DeviceInfo;
105+
options: AndroidSnapshotOptions;
106+
tree: AndroidUiHierarchy;
107+
xml: string;
108+
adb: AndroidAdbExecutor;
109+
interactiveSnapshot: AndroidBuiltSnapshot;
110+
}): Promise<void> {
111+
if (
112+
collectExistingHiddenContentHints(params.interactiveSnapshot.nodes).size > 0 ||
113+
hasAndroidScrollActionAttributes(params.xml)
114+
) {
115+
return;
116+
}
117+
118+
const fullSnapshot = buildUiHierarchySnapshot(params.tree, ANDROID_SNAPSHOT_MAX_NODES, {
119+
...params.options,
120+
interactiveOnly: false,
121+
});
122+
const nativeHints = await deriveScrollableContentHintsIfNeeded(
123+
params.device,
124+
fullSnapshot.nodes,
125+
params.xml,
126+
params.adb,
127+
);
128+
applyHiddenContentHintsToInteractiveNodes(
129+
nativeHints,
130+
fullSnapshot,
131+
params.interactiveSnapshot,
132+
);
133+
if (nativeHints.size === 0) {
134+
const presentationHints = deriveMobileSnapshotHiddenContentHints(attachRefs(fullSnapshot.nodes));
135+
applyHiddenContentHintsToInteractiveNodes(
136+
presentationHints,
137+
fullSnapshot,
138+
params.interactiveSnapshot,
139+
);
140+
}
141+
}
142+
116143
async function captureAndroidUiHierarchy(
117144
device: DeviceInfo,
118145
options: AndroidSnapshotOptions,
@@ -215,8 +242,6 @@ async function captureAndroidUiHierarchyFromHelper(
215242
options.helperWaitForIdleTimeoutMs ?? ANDROID_SNAPSHOT_HELPER_WAIT_FOR_IDLE_TIMEOUT_MS,
216243
timeoutMs: HELPER_CAPTURE_TIMEOUT_MS,
217244
commandTimeoutMs: HELPER_COMMAND_TIMEOUT_MS,
218-
outputPath: createAndroidSnapshotHelperOutputPath(),
219-
emitChunks: false,
220245
}),
221246
{
222247
packageName: artifact.manifest.packageName,
@@ -227,11 +252,6 @@ async function captureAndroidUiHierarchyFromHelper(
227252
);
228253
}
229254

230-
function createAndroidSnapshotHelperOutputPath(): string {
231-
const suffix = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
232-
return `/sdcard/Download/agent-device-snapshot-${suffix}.xml`;
233-
}
234-
235255
function formatAndroidHelperCaptureResult(
236256
capture: AndroidSnapshotHelperOutput,
237257
artifact: AndroidSnapshotHelperArtifact,

0 commit comments

Comments
 (0)