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
1 change: 1 addition & 0 deletions android-snapshot-helper/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.callstack.agentdevice.snapshothelper">
<uses-sdk android:minSdkVersion="23" android:targetSdkVersion="36" />
<uses-permission android:name="android.permission.INTERNET" />

<application
android:debuggable="false"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@
import android.os.Bundle;
import android.util.Base64;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
import android.view.accessibility.AccessibilityWindowInfo;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.util.List;
Expand All @@ -22,8 +29,10 @@ public final class SnapshotInstrumentation extends Instrumentation {
private static final String OUTPUT_FORMAT = "uiautomator-xml";
private static final String HELPER_API_VERSION = "1";
private static final int CHUNK_SIZE = 2 * 1024;
private static final long DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS = 500;
private static final long DEFAULT_WAIT_FOR_IDLE_QUIET_MS = 100;
// Keep the default quiet window short: RN/animation-heavy apps often never become fully idle,
// and callers can still override this for alert-style flows that need a longer settle period.
private static final long DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS = 25;
private static final long DEFAULT_WAIT_FOR_IDLE_QUIET_MS = 25;
private static final long DEFAULT_TIMEOUT_MS = 8_000;
private static final int DEFAULT_MAX_DEPTH = 128;
private static final int DEFAULT_MAX_NODES = 5_000;
Expand All @@ -47,7 +56,47 @@ public void onStart() {
int maxDepth = readIntArgument(arguments, "maxDepth", DEFAULT_MAX_DEPTH);
int maxNodes = readIntArgument(arguments, "maxNodes", DEFAULT_MAX_NODES);
String outputPath = readStringArgument(arguments, "outputPath");
boolean emitChunks = readBooleanArgument(arguments, "emitChunks", true);
int sessionPort = readIntArgument(arguments, "sessionPort", 0);
Bundle result = new Bundle();
putBaseMetadata(result, waitForIdleTimeoutMs, waitForIdleQuietMs, timeoutMs, maxDepth, maxNodes);

try {
if (sessionPort > 0) {
runSnapshotSession(
sessionPort, waitForIdleQuietMs, waitForIdleTimeoutMs, timeoutMs, maxDepth, maxNodes);
result.putString("ok", "true");
result.putString("sessionEnded", "true");
finishSafely(0, result);
return;
}
long startedAtMs = System.currentTimeMillis();
CaptureResult capture =
captureXml(waitForIdleQuietMs, waitForIdleTimeoutMs, timeoutMs, maxDepth, maxNodes);
writeOutputFile(outputPath, capture.xml);
if (emitChunks) {
emitChunks(capture.xml);
}
result.putString("ok", "true");
putCaptureMetadata(result, capture, System.currentTimeMillis() - startedAtMs);
finishSafely(0, result);
} catch (Throwable error) {
result.putString("ok", "false");
result.putString("errorType", error.getClass().getName());
result.putString(
"message",
error.getMessage() == null ? error.getClass().getName() : error.getMessage());
finishSafely(1, result);
}
}

private static void putBaseMetadata(
Bundle result,
long waitForIdleTimeoutMs,
long waitForIdleQuietMs,
long timeoutMs,
int maxDepth,
int maxNodes) {
result.putString("agentDeviceProtocol", PROTOCOL);
result.putString("helperApiVersion", HELPER_API_VERSION);
result.putString("outputFormat", OUTPUT_FORMAT);
Expand All @@ -56,31 +105,143 @@ public void onStart() {
result.putString("timeoutMs", Long.toString(timeoutMs));
result.putString("maxDepth", Integer.toString(maxDepth));
result.putString("maxNodes", Integer.toString(maxNodes));
}

private static void putCaptureMetadata(Bundle result, CaptureResult capture, long elapsedMs) {
result.putString("rootPresent", Boolean.toString(capture.rootPresent));
result.putString("captureMode", capture.captureMode);
result.putString("windowCount", Integer.toString(capture.windowCount));
result.putString("nodeCount", Integer.toString(capture.nodeCount));
result.putString("truncated", Boolean.toString(capture.truncated));
result.putString("elapsedMs", Long.toString(elapsedMs));
}

private void runSnapshotSession(
int sessionPort,
long waitForIdleQuietMs,
long waitForIdleTimeoutMs,
long timeoutMs,
int maxDepth,
int maxNodes)
throws IOException {
try (ServerSocket server =
new ServerSocket(sessionPort, 1, InetAddress.getByName("127.0.0.1"))) {
Bundle ready = new Bundle();
putBaseMetadata(
ready, waitForIdleTimeoutMs, waitForIdleQuietMs, timeoutMs, maxDepth, maxNodes);
ready.putString("sessionReady", "true");
ready.putString("sessionPort", Integer.toString(sessionPort));
sendStatus(2, ready);

while (!Thread.currentThread().isInterrupted()) {
try (Socket socket = server.accept()) {
String command =
new BufferedReader(
new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))
.readLine();
if (command == null) {
writeSessionError(socket.getOutputStream(), "", "java.io.EOFException", "empty command");
continue;
}
String[] parts = command.trim().split("\\s+", 2);
String action = parts.length > 0 ? parts[0] : "";
String requestId = parts.length > 1 ? parts[1] : "";
if ("quit".equals(action)) {
writeSessionOk(socket.getOutputStream(), requestId);
return;
}
if (!"snapshot".equals(action)) {
writeSessionError(
socket.getOutputStream(),
requestId,
"java.lang.IllegalArgumentException",
"unknown session command");
continue;
}
writeSessionSnapshot(
socket.getOutputStream(),
requestId,
waitForIdleQuietMs,
waitForIdleTimeoutMs,
timeoutMs,
maxDepth,
maxNodes);
}
}
}
}

private void writeSessionSnapshot(
OutputStream output,
String requestId,
long waitForIdleQuietMs,
long waitForIdleTimeoutMs,
long timeoutMs,
int maxDepth,
int maxNodes)
throws IOException {
Bundle result = new Bundle();
putBaseMetadata(result, waitForIdleTimeoutMs, waitForIdleQuietMs, timeoutMs, maxDepth, maxNodes);
result.putString("requestId", requestId);
try {
long startedAtMs = System.currentTimeMillis();
CaptureResult capture =
captureXml(waitForIdleQuietMs, waitForIdleTimeoutMs, timeoutMs, maxDepth, maxNodes);
writeOutputFile(outputPath, capture.xml);
emitChunks(capture.xml);
result.putString("ok", "true");
result.putString("rootPresent", Boolean.toString(capture.rootPresent));
result.putString("captureMode", capture.captureMode);
result.putString("windowCount", Integer.toString(capture.windowCount));
result.putString("nodeCount", Integer.toString(capture.nodeCount));
result.putString("truncated", Boolean.toString(capture.truncated));
result.putString("elapsedMs", Long.toString(System.currentTimeMillis() - startedAtMs));
finishSafely(0, result);
putCaptureMetadata(result, capture, System.currentTimeMillis() - startedAtMs);
result.putString("byteLength", Integer.toString(capture.xml.getBytes(StandardCharsets.UTF_8).length));
writeSessionResponse(output, result, capture.xml);
} catch (Throwable error) {
result.putString("ok", "false");
result.putString("errorType", error.getClass().getName());
result.putString(
"message",
writeSessionError(
output,
requestId,
error.getClass().getName(),
error.getMessage() == null ? error.getClass().getName() : error.getMessage());
finishSafely(1, result);
}
}

private static void writeSessionOk(OutputStream output, String requestId) throws IOException {
Bundle result = new Bundle();
result.putString("agentDeviceProtocol", PROTOCOL);
result.putString("helperApiVersion", HELPER_API_VERSION);
result.putString("outputFormat", OUTPUT_FORMAT);
result.putString("requestId", requestId);
result.putString("ok", "true");
writeSessionResponse(output, result, "");
}

private static void writeSessionError(
OutputStream output, String requestId, String errorType, String message) throws IOException {
Bundle result = new Bundle();
result.putString("agentDeviceProtocol", PROTOCOL);
result.putString("helperApiVersion", HELPER_API_VERSION);
result.putString("outputFormat", OUTPUT_FORMAT);
result.putString("requestId", requestId);
result.putString("ok", "false");
result.putString("errorType", errorType);
result.putString("message", message);
writeSessionResponse(output, result, "");
}

private static void writeSessionResponse(OutputStream output, Bundle result, String body)
throws IOException {
StringBuilder headers = new StringBuilder();
for (String key : result.keySet()) {
Object value = result.get(key);
if (value != null) {
headers.append(key).append('=').append(sanitizeHeaderValue(value.toString())).append('\n');
}
}
headers.append('\n');
output.write(headers.toString().getBytes(StandardCharsets.UTF_8));
output.write(body.getBytes(StandardCharsets.UTF_8));
output.flush();
}

private static String sanitizeHeaderValue(String value) {
return value.replace('\r', ' ').replace('\n', ' ');
}

private static String readStringArgument(Bundle arguments, String key) {
if (arguments == null || !arguments.containsKey(key)) {
return null;
Expand Down Expand Up @@ -330,22 +491,34 @@ private static void appendNode(
Rect bounds = new Rect();
node.getBoundsInScreen(bounds);
xml.append("<node");
// Emit only fields consumed by the host parser. Extra boolean attrs made every node larger
// without affecting current snapshot semantics; add fields back here when TS starts reading
// them.
appendAttribute(xml, "index", Integer.toString(nodeIndex));
appendAttribute(xml, "text", node.getText());
appendAttribute(xml, "resource-id", node.getViewIdResourceName());
appendNonEmptyAttribute(xml, "text", node.getText());
appendNonEmptyAttribute(xml, "resource-id", node.getViewIdResourceName());
appendAttribute(xml, "class", node.getClassName());
appendAttribute(xml, "package", node.getPackageName());
appendAttribute(xml, "content-desc", node.getContentDescription());
appendAttribute(xml, "checkable", Boolean.toString(node.isCheckable()));
appendAttribute(xml, "checked", Boolean.toString(node.isChecked()));
appendAttribute(xml, "clickable", Boolean.toString(node.isClickable()));
appendNonEmptyAttribute(xml, "package", node.getPackageName());
appendNonEmptyAttribute(xml, "content-desc", node.getContentDescription());
appendTrueAttribute(xml, "clickable", node.isClickable());
appendAttribute(xml, "enabled", Boolean.toString(node.isEnabled()));
appendAttribute(xml, "focusable", Boolean.toString(node.isFocusable()));
appendAttribute(xml, "focused", Boolean.toString(node.isFocused()));
appendAttribute(xml, "scrollable", Boolean.toString(node.isScrollable()));
appendAttribute(xml, "long-clickable", Boolean.toString(node.isLongClickable()));
appendAttribute(xml, "password", Boolean.toString(node.isPassword()));
appendAttribute(xml, "selected", Boolean.toString(node.isSelected()));
appendTrueAttribute(xml, "focusable", node.isFocusable());
appendTrueAttribute(xml, "focused", node.isFocused());
boolean scrollable = node.isScrollable();
if (scrollable) {
appendAttribute(xml, "scrollable", "true");
appendAttribute(
xml,
"can-scroll-forward",
Boolean.toString(
hasAccessibilityAction(node, AccessibilityAction.ACTION_SCROLL_FORWARD)));
appendAttribute(
xml,
"can-scroll-backward",
Boolean.toString(
hasAccessibilityAction(node, AccessibilityAction.ACTION_SCROLL_BACKWARD)));
}
appendTrueAttribute(xml, "password", node.isPassword());
appendAttribute(
xml,
"bounds",
Expand Down Expand Up @@ -385,6 +558,19 @@ private static void appendNode(
xml.append("</node>");
}

private static void appendNonEmptyAttribute(StringBuilder xml, String name, CharSequence value) {
if (value == null || value.length() == 0) {
return;
}
appendAttribute(xml, name, value);
}

private static void appendTrueAttribute(StringBuilder xml, String name, boolean value) {
if (value) {
appendAttribute(xml, name, "true");
}
}

private static void appendAttribute(StringBuilder xml, String name, CharSequence value) {
String stringValue = value == null ? "" : value.toString();
xml.append(' ');
Expand All @@ -394,6 +580,12 @@ private static void appendAttribute(StringBuilder xml, String name, CharSequence
xml.append('"');
}

private static boolean hasAccessibilityAction(
AccessibilityNodeInfo node, AccessibilityAction action) {
List<AccessibilityAction> actions = node.getActionList();
return actions != null && actions.contains(action);
}

private static void appendEscaped(StringBuilder xml, String value) {
for (int index = 0; index < value.length(); index += 1) {
char character = value.charAt(index);
Expand Down Expand Up @@ -459,6 +651,17 @@ private static int readIntArgument(Bundle arguments, String name, int fallback)
}
}

private static boolean readBooleanArgument(Bundle arguments, String name, boolean fallback) {
if (arguments == null) {
return fallback;
}
String raw = arguments.getString(name);
if (raw == null || raw.trim().isEmpty()) {
return fallback;
}
return Boolean.parseBoolean(raw.trim());
}

private static final class CaptureStats {
int nodeCount;
boolean truncated;
Expand Down
Loading
Loading