88import android .util .Base64 ;
99import android .view .accessibility .AccessibilityNodeInfo ;
1010import android .view .accessibility .AccessibilityWindowInfo ;
11+ import java .io .File ;
12+ import java .io .FileOutputStream ;
13+ import java .io .IOException ;
14+ import java .lang .reflect .Field ;
1115import java .nio .charset .StandardCharsets ;
1216import java .util .List ;
1317import java .util .Locale ;
@@ -17,8 +21,9 @@ public final class SnapshotInstrumentation extends Instrumentation {
1721 private static final String PROTOCOL = "android-snapshot-helper-v1" ;
1822 private static final String OUTPUT_FORMAT = "uiautomator-xml" ;
1923 private static final String HELPER_API_VERSION = "1" ;
20- private static final int CHUNK_SIZE = 8 * 1024 ;
24+ private static final int CHUNK_SIZE = 2 * 1024 ;
2125 private static final long DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS = 500 ;
26+ private static final long DEFAULT_WAIT_FOR_IDLE_QUIET_MS = 100 ;
2227 private static final long DEFAULT_TIMEOUT_MS = 8_000 ;
2328 private static final int DEFAULT_MAX_DEPTH = 128 ;
2429 private static final int DEFAULT_MAX_NODES = 5_000 ;
@@ -36,21 +41,27 @@ public void onStart() {
3641 super .onStart ();
3742 long waitForIdleTimeoutMs =
3843 readLongArgument (arguments , "waitForIdleTimeoutMs" , DEFAULT_WAIT_FOR_IDLE_TIMEOUT_MS );
44+ long waitForIdleQuietMs =
45+ readLongArgument (arguments , "waitForIdleQuietMs" , DEFAULT_WAIT_FOR_IDLE_QUIET_MS );
3946 long timeoutMs = readLongArgument (arguments , "timeoutMs" , DEFAULT_TIMEOUT_MS );
4047 int maxDepth = readIntArgument (arguments , "maxDepth" , DEFAULT_MAX_DEPTH );
4148 int maxNodes = readIntArgument (arguments , "maxNodes" , DEFAULT_MAX_NODES );
49+ String outputPath = readStringArgument (arguments , "outputPath" );
4250 Bundle result = new Bundle ();
4351 result .putString ("agentDeviceProtocol" , PROTOCOL );
4452 result .putString ("helperApiVersion" , HELPER_API_VERSION );
4553 result .putString ("outputFormat" , OUTPUT_FORMAT );
4654 result .putString ("waitForIdleTimeoutMs" , Long .toString (waitForIdleTimeoutMs ));
55+ result .putString ("waitForIdleQuietMs" , Long .toString (waitForIdleQuietMs ));
4756 result .putString ("timeoutMs" , Long .toString (timeoutMs ));
4857 result .putString ("maxDepth" , Integer .toString (maxDepth ));
4958 result .putString ("maxNodes" , Integer .toString (maxNodes ));
5059
5160 try {
5261 long startedAtMs = System .currentTimeMillis ();
53- CaptureResult capture = captureXml (waitForIdleTimeoutMs , maxDepth , maxNodes );
62+ CaptureResult capture =
63+ captureXml (waitForIdleQuietMs , waitForIdleTimeoutMs , timeoutMs , maxDepth , maxNodes );
64+ writeOutputFile (outputPath , capture .xml );
5465 emitChunks (capture .xml );
5566 result .putString ("ok" , "true" );
5667 result .putString ("rootPresent" , Boolean .toString (capture .rootPresent ));
@@ -59,26 +70,111 @@ public void onStart() {
5970 result .putString ("nodeCount" , Integer .toString (capture .nodeCount ));
6071 result .putString ("truncated" , Boolean .toString (capture .truncated ));
6172 result .putString ("elapsedMs" , Long .toString (System .currentTimeMillis () - startedAtMs ));
62- finish (0 , result );
73+ finishSafely (0 , result );
6374 } catch (Throwable error ) {
6475 result .putString ("ok" , "false" );
6576 result .putString ("errorType" , error .getClass ().getName ());
6677 result .putString (
6778 "message" ,
6879 error .getMessage () == null ? error .getClass ().getName () : error .getMessage ());
69- finish (1 , result );
80+ finishSafely (1 , result );
81+ }
82+ }
83+
84+ private static String readStringArgument (Bundle arguments , String key ) {
85+ if (arguments == null || !arguments .containsKey (key )) {
86+ return null ;
87+ }
88+ String value = arguments .getString (key );
89+ return value == null || value .trim ().isEmpty () ? null : value .trim ();
90+ }
91+
92+ private static void writeOutputFile (String outputPath , String xml ) throws IOException {
93+ if (outputPath == null ) {
94+ return ;
95+ }
96+ File file = new File (outputPath );
97+ File parent = file .getParentFile ();
98+ if (parent != null ) {
99+ parent .mkdirs ();
100+ }
101+ try (FileOutputStream stream = new FileOutputStream (file , false )) {
102+ stream .write (xml .getBytes (StandardCharsets .UTF_8 ));
103+ }
104+ }
105+
106+ private void finishSafely (int resultCode , Bundle result ) {
107+ RuntimeException lastError = null ;
108+ for (int attempt = 0 ; attempt < 100 ; attempt += 1 ) {
109+ try {
110+ finish (resultCode , result );
111+ return ;
112+ } catch (IllegalStateException error ) {
113+ if (!isUiAutomationConnectingError (error )) {
114+ throw error ;
115+ }
116+ lastError = error ;
117+ sleep (100 );
118+ }
119+ }
120+ detachUiAutomationBeforeFinish ();
121+ try {
122+ finish (resultCode , result );
123+ return ;
124+ } catch (IllegalStateException error ) {
125+ if (!isUiAutomationConnectingError (error )) {
126+ throw error ;
127+ }
128+ lastError = error ;
129+ }
130+ throw lastError ;
131+ }
132+
133+ private void detachUiAutomationBeforeFinish () {
134+ try {
135+ Field field = Instrumentation .class .getDeclaredField ("mUiAutomation" );
136+ field .setAccessible (true );
137+ field .set (this , null );
138+ } catch (ReflectiveOperationException | RuntimeException ignored ) {
139+ // If the platform blocks reflection, preserve the original finish failure below.
140+ }
141+ }
142+
143+ private static boolean isUiAutomationConnectingError (IllegalStateException error ) {
144+ String message = error .getMessage ();
145+ return message != null && message .contains ("while connecting" );
146+ }
147+
148+ private static boolean isUiAutomationNotConnectedError (IllegalStateException error ) {
149+ String message = error .getMessage ();
150+ return message != null && message .toLowerCase (Locale .ROOT ).contains ("not connected" );
151+ }
152+
153+ private static void sleep (long millis ) {
154+ try {
155+ Thread .sleep (millis );
156+ } catch (InterruptedException error ) {
157+ Thread .currentThread ().interrupt ();
70158 }
71159 }
72160
73161 @ SuppressWarnings ("deprecation" )
74- private CaptureResult captureXml (long waitForIdleTimeoutMs , int maxDepth , int maxNodes )
162+ private CaptureResult captureXml (
163+ long waitForIdleQuietMs ,
164+ long waitForIdleTimeoutMs ,
165+ long timeoutMs ,
166+ int maxDepth ,
167+ int maxNodes )
75168 throws TimeoutException {
76- UiAutomation automation = getUiAutomation ( );
169+ UiAutomation automation = getConnectedUiAutomation ( timeoutMs );
77170 enableInteractiveWindowRetrieval (automation );
78171 if (waitForIdleTimeoutMs > 0 ) {
79172 try {
80- // Best-effort settle: avoids empty roots without inheriting UIAutomator's long idle wait.
81- automation .waitForIdle (waitForIdleTimeoutMs , waitForIdleTimeoutMs );
173+ // Best-effort settle: wait for the accessibility stream to become idle, but require only
174+ // a short quiet window once it does. Using the full timeout as the quiet window made every
175+ // stable snapshot pay a fixed 500 ms tax.
176+ long quietMs = Math .min (waitForIdleQuietMs , waitForIdleTimeoutMs );
177+ automation .waitForIdle (quietMs , waitForIdleTimeoutMs );
82178 } catch (TimeoutException ignored ) {
83179 // Busy or animated apps can still expose a usable root; capture whatever is available.
84180 }
@@ -109,6 +205,30 @@ private CaptureResult captureXml(long waitForIdleTimeoutMs, int maxDepth, int ma
109205 xml .toString (), windowCount > 0 , captureMode , windowCount , stats .nodeCount , stats .truncated );
110206 }
111207
208+ private UiAutomation getConnectedUiAutomation (long timeoutMs ) throws TimeoutException {
209+ long deadlineMs = System .currentTimeMillis () + Math .max (1 , timeoutMs );
210+ UiAutomation automation = getUiAutomation ();
211+ RuntimeException lastError = null ;
212+ while (System .currentTimeMillis () <= deadlineMs ) {
213+ try {
214+ automation .getServiceInfo ();
215+ return automation ;
216+ } catch (IllegalStateException error ) {
217+ if (!isUiAutomationConnectingError (error ) && !isUiAutomationNotConnectedError (error )) {
218+ throw error ;
219+ }
220+ lastError = error ;
221+ }
222+ sleep (50 );
223+ }
224+ TimeoutException timeout =
225+ new TimeoutException ("Timed out waiting for Android UiAutomation to connect" );
226+ if (lastError != null ) {
227+ timeout .initCause (lastError );
228+ }
229+ throw timeout ;
230+ }
231+
112232 private static void enableInteractiveWindowRetrieval (UiAutomation automation ) {
113233 AccessibilityServiceInfo serviceInfo ;
114234 try {
0 commit comments