Skip to content

Commit 308d2af

Browse files
committed
perf: add persistent Android snapshot helper session
1 parent 61cae9c commit 308d2af

14 files changed

Lines changed: 921 additions & 47 deletions

File tree

android-snapshot-helper/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
xmlns:android="http://schemas.android.com/apk/res/android"
33
package="com.callstack.agentdevice.snapshothelper">
44
<uses-sdk android:minSdkVersion="23" android:targetSdkVersion="36" />
5+
<uses-permission android:name="android.permission.INTERNET" />
56

67
<application
78
android:debuggable="false"

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

Lines changed: 169 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,15 @@
99
import android.view.accessibility.AccessibilityNodeInfo;
1010
import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
1111
import android.view.accessibility.AccessibilityWindowInfo;
12+
import java.io.BufferedReader;
1213
import java.io.File;
1314
import java.io.FileOutputStream;
1415
import java.io.IOException;
16+
import java.io.InputStreamReader;
17+
import java.io.OutputStream;
18+
import java.net.InetAddress;
19+
import java.net.ServerSocket;
20+
import java.net.Socket;
1521
import java.lang.reflect.Field;
1622
import java.nio.charset.StandardCharsets;
1723
import java.util.List;
@@ -51,17 +57,19 @@ public void onStart() {
5157
int maxNodes = readIntArgument(arguments, "maxNodes", DEFAULT_MAX_NODES);
5258
String outputPath = readStringArgument(arguments, "outputPath");
5359
boolean emitChunks = readBooleanArgument(arguments, "emitChunks", true);
60+
int sessionPort = readIntArgument(arguments, "sessionPort", 0);
5461
Bundle result = new Bundle();
55-
result.putString("agentDeviceProtocol", PROTOCOL);
56-
result.putString("helperApiVersion", HELPER_API_VERSION);
57-
result.putString("outputFormat", OUTPUT_FORMAT);
58-
result.putString("waitForIdleTimeoutMs", Long.toString(waitForIdleTimeoutMs));
59-
result.putString("waitForIdleQuietMs", Long.toString(waitForIdleQuietMs));
60-
result.putString("timeoutMs", Long.toString(timeoutMs));
61-
result.putString("maxDepth", Integer.toString(maxDepth));
62-
result.putString("maxNodes", Integer.toString(maxNodes));
62+
putBaseMetadata(result, waitForIdleTimeoutMs, waitForIdleQuietMs, timeoutMs, maxDepth, maxNodes);
6363

6464
try {
65+
if (sessionPort > 0) {
66+
runSnapshotSession(
67+
sessionPort, waitForIdleQuietMs, waitForIdleTimeoutMs, timeoutMs, maxDepth, maxNodes);
68+
result.putString("ok", "true");
69+
result.putString("sessionEnded", "true");
70+
finishSafely(0, result);
71+
return;
72+
}
6573
long startedAtMs = System.currentTimeMillis();
6674
CaptureResult capture =
6775
captureXml(waitForIdleQuietMs, waitForIdleTimeoutMs, timeoutMs, maxDepth, maxNodes);
@@ -70,12 +78,7 @@ public void onStart() {
7078
emitChunks(capture.xml);
7179
}
7280
result.putString("ok", "true");
73-
result.putString("rootPresent", Boolean.toString(capture.rootPresent));
74-
result.putString("captureMode", capture.captureMode);
75-
result.putString("windowCount", Integer.toString(capture.windowCount));
76-
result.putString("nodeCount", Integer.toString(capture.nodeCount));
77-
result.putString("truncated", Boolean.toString(capture.truncated));
78-
result.putString("elapsedMs", Long.toString(System.currentTimeMillis() - startedAtMs));
81+
putCaptureMetadata(result, capture, System.currentTimeMillis() - startedAtMs);
7982
finishSafely(0, result);
8083
} catch (Throwable error) {
8184
result.putString("ok", "false");
@@ -87,6 +90,158 @@ public void onStart() {
8790
}
8891
}
8992

93+
private static void putBaseMetadata(
94+
Bundle result,
95+
long waitForIdleTimeoutMs,
96+
long waitForIdleQuietMs,
97+
long timeoutMs,
98+
int maxDepth,
99+
int maxNodes) {
100+
result.putString("agentDeviceProtocol", PROTOCOL);
101+
result.putString("helperApiVersion", HELPER_API_VERSION);
102+
result.putString("outputFormat", OUTPUT_FORMAT);
103+
result.putString("waitForIdleTimeoutMs", Long.toString(waitForIdleTimeoutMs));
104+
result.putString("waitForIdleQuietMs", Long.toString(waitForIdleQuietMs));
105+
result.putString("timeoutMs", Long.toString(timeoutMs));
106+
result.putString("maxDepth", Integer.toString(maxDepth));
107+
result.putString("maxNodes", Integer.toString(maxNodes));
108+
}
109+
110+
private static void putCaptureMetadata(Bundle result, CaptureResult capture, long elapsedMs) {
111+
result.putString("rootPresent", Boolean.toString(capture.rootPresent));
112+
result.putString("captureMode", capture.captureMode);
113+
result.putString("windowCount", Integer.toString(capture.windowCount));
114+
result.putString("nodeCount", Integer.toString(capture.nodeCount));
115+
result.putString("truncated", Boolean.toString(capture.truncated));
116+
result.putString("elapsedMs", Long.toString(elapsedMs));
117+
}
118+
119+
private void runSnapshotSession(
120+
int sessionPort,
121+
long waitForIdleQuietMs,
122+
long waitForIdleTimeoutMs,
123+
long timeoutMs,
124+
int maxDepth,
125+
int maxNodes)
126+
throws IOException {
127+
try (ServerSocket server =
128+
new ServerSocket(sessionPort, 1, InetAddress.getByName("127.0.0.1"))) {
129+
Bundle ready = new Bundle();
130+
putBaseMetadata(
131+
ready, waitForIdleTimeoutMs, waitForIdleQuietMs, timeoutMs, maxDepth, maxNodes);
132+
ready.putString("sessionReady", "true");
133+
ready.putString("sessionPort", Integer.toString(sessionPort));
134+
sendStatus(2, ready);
135+
136+
while (!Thread.currentThread().isInterrupted()) {
137+
try (Socket socket = server.accept()) {
138+
String command =
139+
new BufferedReader(
140+
new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))
141+
.readLine();
142+
if (command == null) {
143+
writeSessionError(socket.getOutputStream(), "", "java.io.EOFException", "empty command");
144+
continue;
145+
}
146+
String[] parts = command.trim().split("\\s+", 2);
147+
String action = parts.length > 0 ? parts[0] : "";
148+
String requestId = parts.length > 1 ? parts[1] : "";
149+
if ("quit".equals(action)) {
150+
writeSessionOk(socket.getOutputStream(), requestId);
151+
return;
152+
}
153+
if (!"snapshot".equals(action)) {
154+
writeSessionError(
155+
socket.getOutputStream(),
156+
requestId,
157+
"java.lang.IllegalArgumentException",
158+
"unknown session command");
159+
continue;
160+
}
161+
writeSessionSnapshot(
162+
socket.getOutputStream(),
163+
requestId,
164+
waitForIdleQuietMs,
165+
waitForIdleTimeoutMs,
166+
timeoutMs,
167+
maxDepth,
168+
maxNodes);
169+
}
170+
}
171+
}
172+
}
173+
174+
private void writeSessionSnapshot(
175+
OutputStream output,
176+
String requestId,
177+
long waitForIdleQuietMs,
178+
long waitForIdleTimeoutMs,
179+
long timeoutMs,
180+
int maxDepth,
181+
int maxNodes)
182+
throws IOException {
183+
Bundle result = new Bundle();
184+
putBaseMetadata(result, waitForIdleTimeoutMs, waitForIdleQuietMs, timeoutMs, maxDepth, maxNodes);
185+
result.putString("requestId", requestId);
186+
try {
187+
long startedAtMs = System.currentTimeMillis();
188+
CaptureResult capture =
189+
captureXml(waitForIdleQuietMs, waitForIdleTimeoutMs, timeoutMs, maxDepth, maxNodes);
190+
result.putString("ok", "true");
191+
putCaptureMetadata(result, capture, System.currentTimeMillis() - startedAtMs);
192+
result.putString("byteLength", Integer.toString(capture.xml.getBytes(StandardCharsets.UTF_8).length));
193+
writeSessionResponse(output, result, capture.xml);
194+
} catch (Throwable error) {
195+
writeSessionError(
196+
output,
197+
requestId,
198+
error.getClass().getName(),
199+
error.getMessage() == null ? error.getClass().getName() : error.getMessage());
200+
}
201+
}
202+
203+
private static void writeSessionOk(OutputStream output, String requestId) throws IOException {
204+
Bundle result = new Bundle();
205+
result.putString("agentDeviceProtocol", PROTOCOL);
206+
result.putString("helperApiVersion", HELPER_API_VERSION);
207+
result.putString("outputFormat", OUTPUT_FORMAT);
208+
result.putString("requestId", requestId);
209+
result.putString("ok", "true");
210+
writeSessionResponse(output, result, "");
211+
}
212+
213+
private static void writeSessionError(
214+
OutputStream output, String requestId, String errorType, String message) throws IOException {
215+
Bundle result = new Bundle();
216+
result.putString("agentDeviceProtocol", PROTOCOL);
217+
result.putString("helperApiVersion", HELPER_API_VERSION);
218+
result.putString("outputFormat", OUTPUT_FORMAT);
219+
result.putString("requestId", requestId);
220+
result.putString("ok", "false");
221+
result.putString("errorType", errorType);
222+
result.putString("message", message);
223+
writeSessionResponse(output, result, "");
224+
}
225+
226+
private static void writeSessionResponse(OutputStream output, Bundle result, String body)
227+
throws IOException {
228+
StringBuilder headers = new StringBuilder();
229+
for (String key : result.keySet()) {
230+
Object value = result.get(key);
231+
if (value != null) {
232+
headers.append(key).append('=').append(sanitizeHeaderValue(value.toString())).append('\n');
233+
}
234+
}
235+
headers.append('\n');
236+
output.write(headers.toString().getBytes(StandardCharsets.UTF_8));
237+
output.write(body.getBytes(StandardCharsets.UTF_8));
238+
output.flush();
239+
}
240+
241+
private static String sanitizeHeaderValue(String value) {
242+
return value.replace('\r', ' ').replace('\n', ' ');
243+
}
244+
90245
private static String readStringArgument(Bundle arguments, String key) {
91246
if (arguments == null || !arguments.containsKey(key)) {
92247
return null;

docs/adr/0002-persistent-platform-helper-sessions.md

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

33
## Status
44

5-
Accepted (implementation pending)
5+
Accepted
66

77
## Context
88

@@ -48,8 +48,8 @@ The session pattern is:
4848
For Android snapshots, productize a persistent helper mode that keeps `UiAutomation` alive and
4949
serves fresh snapshot requests over an `adb forward` socket. Do not add snapshot result caching as
5050
part of that first step. The first reliable win is infrastructure reuse, not data reuse. The current
51-
PR only lands one-shot snapshot improvements and this decision record; the persistent Android
52-
session implementation is follow-up work.
51+
implementation keeps the existing one-shot instrumentation helper as the fallback for startup,
52+
socket, protocol, and request failures.
5353

5454
For iOS, keep the XCTest runner session as the reference implementation for lifecycle and
5555
invalidation behavior. Android does not need to copy iOS internals, but it should reuse the same

0 commit comments

Comments
 (0)