droidrun 0.3.1__py3-none-any.whl → 0.3.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
droidrun/tools/adb.py CHANGED
@@ -3,29 +3,50 @@ UI Actions - Core UI interaction tools for Android device control.
3
3
  """
4
4
 
5
5
  import os
6
- import re
6
+ import io
7
7
  import json
8
8
  import time
9
- import tempfile
10
- import asyncio
11
- import aiofiles
12
- import contextlib
13
9
  import logging
14
- from typing import Optional, Dict, Tuple, List, Any
15
- from droidrun.adb.device import Device
16
- from droidrun.adb.manager import DeviceManager
10
+ from llama_index.core.workflow import Context
11
+ from typing_extensions import Optional, Dict, Tuple, List, Any, Type, Self
12
+ from droidrun.agent.common.events import (
13
+ InputTextActionEvent,
14
+ KeyPressActionEvent,
15
+ StartAppEvent,
16
+ SwipeActionEvent,
17
+ TapActionEvent,
18
+ DragActionEvent,
19
+ )
17
20
  from droidrun.tools.tools import Tools
21
+ from adbutils import adb
22
+ import requests
23
+ import base64
24
+
25
+ logger = logging.getLogger("droidrun-tools")
18
26
 
19
- logger = logging.getLogger("droidrun-adb-tools")
20
27
 
21
28
  class AdbTools(Tools):
22
29
  """Core UI interaction tools for Android device control."""
23
30
 
24
- def __init__(self, serial: str = "emulator-5554") -> None:
31
+ def __init__(
32
+ self, serial: str | None = None, use_tcp: bool = False, tcp_port: int = 8080
33
+ ) -> None:
34
+ """Initialize the AdbTools instance.
35
+
36
+ Args:
37
+ serial: Device serial number
38
+ use_tcp: Whether to use TCP communication (default: False)
39
+ tcp_port: TCP port for communication (default: 8080)
40
+ """
41
+ self.device = adb.device(serial=serial)
42
+ self.use_tcp = use_tcp
43
+ self.tcp_port = tcp_port
44
+ self.tcp_base_url = f"http://localhost:{tcp_port}"
45
+ self.tcp_forwarded = False
46
+
47
+ self._ctx = None
25
48
  # Instance‐level cache for clickable elements (index-based tapping)
26
49
  self.clickable_elements_cache: List[Dict[str, Any]] = []
27
- self.serial = serial
28
- self.device_manager = DeviceManager()
29
50
  self.last_screenshot = None
30
51
  self.reason = None
31
52
  self.success = None
@@ -35,85 +56,113 @@ class AdbTools(Tools):
35
56
  # Store all screenshots with timestamps
36
57
  self.screenshots: List[Dict[str, Any]] = []
37
58
 
38
- def get_device_serial(self) -> str:
39
- """Get the device serial from the instance or environment variable."""
40
- # First try using the instance's serial
41
- if self.serial:
42
- return self.serial
59
+ # Set up TCP forwarding if requested
60
+ if self.use_tcp:
61
+ self.setup_tcp_forward()
43
62
 
44
- async def get_device(self) -> Optional[Device]:
45
- """Get the device instance using the instance's serial or from environment variable.
63
+ def setup_tcp_forward(self) -> bool:
64
+ """
65
+ Set up ADB TCP port forwarding for communication with the portal app.
46
66
 
47
67
  Returns:
48
- Device instance or None if not found
68
+ bool: True if forwarding was set up successfully, False otherwise
49
69
  """
50
- serial = self.get_device_serial()
51
- if not serial:
52
- raise ValueError("No device serial specified - set device_serial parameter")
53
-
54
- device = await self.device_manager.get_device(serial)
55
- if not device:
56
- raise ValueError(f"Device {serial} not found")
70
+ try:
71
+ logger.debug(
72
+ f"Setting up TCP port forwarding: tcp:{self.tcp_port} tcp:{self.tcp_port}"
73
+ )
74
+ # Use adb forward command to set up port forwarding
75
+ result = self.device.forward(f"tcp:{self.tcp_port}", f"tcp:{self.tcp_port}")
76
+ self.tcp_forwarded = True
77
+ logger.debug(f"TCP port forwarding set up successfully: {result}")
57
78
 
58
- return device
79
+ # Test the connection with a ping
80
+ try:
81
+ response = requests.get(f"{self.tcp_base_url}/ping", timeout=5)
82
+ if response.status_code == 200:
83
+ logger.debug("TCP connection test successful")
84
+ return True
85
+ else:
86
+ logger.warning(
87
+ f"TCP connection test failed with status: {response.status_code}"
88
+ )
89
+ return False
90
+ except requests.exceptions.RequestException as e:
91
+ logger.warning(f"TCP connection test failed: {e}")
92
+ return False
59
93
 
60
- def parse_package_list(self, output: str) -> List[Dict[str, str]]:
61
- """Parse the output of 'pm list packages -f' command.
94
+ except Exception as e:
95
+ logger.error(f"Failed to set up TCP port forwarding: {e}")
96
+ self.tcp_forwarded = False
97
+ return False
62
98
 
63
- Args:
64
- output: Raw command output from 'pm list packages -f'
99
+ def teardown_tcp_forward(self) -> bool:
100
+ """
101
+ Remove ADB TCP port forwarding.
65
102
 
66
103
  Returns:
67
- List of dictionaries containing package info with 'package' and 'path' keys
104
+ bool: True if forwarding was removed successfully, False otherwise
68
105
  """
69
- apps = []
70
- for line in output.splitlines():
71
- if line.startswith("package:"):
72
- # Format is: "package:/path/to/base.apk=com.package.name"
73
- path_and_pkg = line[8:] # Strip "package:"
74
- if "=" in path_and_pkg:
75
- path, package = path_and_pkg.rsplit("=", 1)
76
- apps.append({"package": package.strip(), "path": path.strip()})
77
- return apps
106
+ try:
107
+ if self.tcp_forwarded:
108
+ logger.debug(f"Removing TCP port forwarding for port {self.tcp_port}")
109
+ result = self.device.forward_remove(f"tcp:{self.tcp_port}")
110
+ self.tcp_forwarded = False
111
+ logger.debug(f"TCP port forwarding removed: {result}")
112
+ return True
113
+ return True
114
+ except Exception as e:
115
+ logger.error(f"Failed to remove TCP port forwarding: {e}")
116
+ return False
117
+
118
+ def __del__(self):
119
+ """Cleanup when the object is destroyed."""
120
+ if hasattr(self, "tcp_forwarded") and self.tcp_forwarded:
121
+ self.teardown_tcp_forward()
78
122
 
79
- def _parse_content_provider_output(self, raw_output: str) -> Optional[Dict[str, Any]]:
123
+ def _set_context(self, ctx: Context):
124
+ self._ctx = ctx
125
+
126
+ def _parse_content_provider_output(
127
+ self, raw_output: str
128
+ ) -> Optional[Dict[str, Any]]:
80
129
  """
81
130
  Parse the raw ADB content provider output and extract JSON data.
82
-
131
+
83
132
  Args:
84
133
  raw_output (str): Raw output from ADB content query command
85
-
134
+
86
135
  Returns:
87
136
  dict: Parsed JSON data or None if parsing failed
88
137
  """
89
138
  # The ADB content query output format is: "Row: 0 result={json_data}"
90
139
  # We need to extract the JSON part after "result="
91
- lines = raw_output.strip().split('\n')
92
-
140
+ lines = raw_output.strip().split("\n")
141
+
93
142
  for line in lines:
94
143
  line = line.strip()
95
-
144
+
96
145
  # Look for lines that contain "result=" pattern
97
146
  if "result=" in line:
98
147
  # Extract everything after "result="
99
148
  result_start = line.find("result=") + 7
100
149
  json_str = line[result_start:]
101
-
150
+
102
151
  try:
103
152
  # Parse the JSON string
104
153
  json_data = json.loads(json_str)
105
154
  return json_data
106
155
  except json.JSONDecodeError:
107
156
  continue
108
-
157
+
109
158
  # Fallback: try to parse lines that start with { or [
110
- elif line.startswith('{') or line.startswith('['):
159
+ elif line.startswith("{") or line.startswith("["):
111
160
  try:
112
161
  json_data = json.loads(line)
113
162
  return json_data
114
163
  except json.JSONDecodeError:
115
164
  continue
116
-
165
+
117
166
  # If no valid JSON found in individual lines, try the entire output
118
167
  try:
119
168
  json_data = json.loads(raw_output.strip())
@@ -121,7 +170,7 @@ class AdbTools(Tools):
121
170
  except json.JSONDecodeError:
122
171
  return None
123
172
 
124
- async def tap_by_index(self, index: int, serial: Optional[str] = None) -> str:
173
+ def tap_by_index(self, index: int) -> str:
125
174
  """
126
175
  Tap on a UI element by its index.
127
176
 
@@ -193,18 +242,32 @@ class AdbTools(Tools):
193
242
  x = (left + right) // 2
194
243
  y = (top + bottom) // 2
195
244
 
245
+ logger.debug(
246
+ f"Tapping element with index {index} at coordinates ({x}, {y})"
247
+ )
196
248
  # Get the device and tap at the coordinates
197
- if serial:
198
- device = await self.device_manager.get_device(serial)
199
- if not device:
200
- return f"Error: Device {serial} not found"
201
- else:
202
- device = await self.get_device()
249
+ self.device.click(x, y)
250
+ logger.debug(f"Tapped element with index {index} at coordinates ({x}, {y})")
203
251
 
204
- await device.tap(x, y)
252
+ # Emit coordinate action event for trajectory recording
253
+
254
+ if self._ctx:
255
+ element_text = element.get("text", "No text")
256
+ element_class = element.get("className", "Unknown class")
257
+
258
+ tap_event = TapActionEvent(
259
+ action_type="tap",
260
+ description=f"Tap element at index {index}: '{element_text}' ({element_class}) at coordinates ({x}, {y})",
261
+ x=x,
262
+ y=y,
263
+ element_index=index,
264
+ element_text=element_text,
265
+ element_bounds=bounds_str,
266
+ )
267
+ self._ctx.write_event_to_stream(tap_event)
205
268
 
206
269
  # Add a small delay to allow UI to update
207
- await asyncio.sleep(0.5)
270
+ time.sleep(0.5)
208
271
 
209
272
  # Create a descriptive response
210
273
  response_parts = []
@@ -229,7 +292,7 @@ class AdbTools(Tools):
229
292
  return f"Error: {str(e)}"
230
293
 
231
294
  # Rename the old tap function to tap_by_coordinates for backward compatibility
232
- async def tap_by_coordinates(self, x: int, y: int) -> bool:
295
+ def tap_by_coordinates(self, x: int, y: int) -> bool:
233
296
  """
234
297
  Tap on the device screen at specific coordinates.
235
298
 
@@ -241,22 +304,16 @@ class AdbTools(Tools):
241
304
  Bool indicating success or failure
242
305
  """
243
306
  try:
244
- if self.serial:
245
- device = await self.device_manager.get_device(self.serial)
246
- if not device:
247
- return f"Error: Device {self.serial} not found"
248
- else:
249
- device = await self.get_device()
250
-
251
- await device.tap(x, y)
252
- print(f"Tapped at coordinates ({x}, {y})")
307
+ logger.debug(f"Tapping at coordinates ({x}, {y})")
308
+ self.device.click(x, y)
309
+ logger.debug(f"Tapped at coordinates ({x}, {y})")
253
310
  return True
254
311
  except ValueError as e:
255
- print(f"Error: {str(e)}")
312
+ logger.debug(f"Error: {str(e)}")
256
313
  return False
257
314
 
258
315
  # Replace the old tap function with the new one
259
- async def tap(self, index: int) -> str:
316
+ def tap(self, index: int) -> str:
260
317
  """
261
318
  Tap on a UI element by its index.
262
319
 
@@ -269,10 +326,15 @@ class AdbTools(Tools):
269
326
  Returns:
270
327
  Result message
271
328
  """
272
- return await self.tap_by_index(index)
273
-
274
- async def swipe(
275
- self, start_x: int, start_y: int, end_x: int, end_y: int, duration_ms: int = 300
329
+ return self.tap_by_index(index)
330
+
331
+ def swipe(
332
+ self,
333
+ start_x: int,
334
+ start_y: int,
335
+ end_x: int,
336
+ end_y: int,
337
+ duration_ms: float = 300,
276
338
  ) -> bool:
277
339
  """
278
340
  Performs a straight-line swipe gesture on the device screen.
@@ -282,27 +344,76 @@ class AdbTools(Tools):
282
344
  start_y: Starting Y coordinate
283
345
  end_x: Ending X coordinate
284
346
  end_y: Ending Y coordinate
285
- duration_ms: Duration of swipe in milliseconds
347
+ duration: Duration of swipe in seconds
286
348
  Returns:
287
349
  Bool indicating success or failure
288
350
  """
289
351
  try:
290
- if self.serial:
291
- device = await self.device_manager.get_device(self.serial)
292
- if not device:
293
- return f"Error: Device {self.serial} not found"
294
- else:
295
- device = await self.get_device()
296
352
 
297
- await device.swipe(start_x, start_y, end_x, end_y, duration_ms)
298
- await asyncio.sleep(1)
299
- print(f"Swiped from ({start_x}, {start_y}) to ({end_x}, {end_y}) in {duration_ms}ms")
353
+ if self._ctx:
354
+ swipe_event = SwipeActionEvent(
355
+ action_type="swipe",
356
+ description=f"Swipe from ({start_x}, {start_y}) to ({end_x}, {end_y}) in {duration_ms} milliseconds",
357
+ start_x=start_x,
358
+ start_y=start_y,
359
+ end_x=end_x,
360
+ end_y=end_y,
361
+ duration_ms=duration_ms,
362
+ )
363
+ self._ctx.write_event_to_stream(swipe_event)
364
+
365
+ self.device.swipe(start_x, start_y, end_x, end_y, float(duration_ms / 1000))
366
+ time.sleep(duration_ms / 1000)
367
+ logger.debug(
368
+ f"Swiped from ({start_x}, {start_y}) to ({end_x}, {end_y}) in {duration_ms} milliseconds"
369
+ )
370
+ return True
371
+ except ValueError as e:
372
+ print(f"Error: {str(e)}")
373
+ return False
374
+
375
+ def drag(
376
+ self, start_x: int, start_y: int, end_x: int, end_y: int, duration: float = 3
377
+ ) -> bool:
378
+ """
379
+ Performs a straight-line drag and drop gesture on the device screen.
380
+ Args:
381
+ start_x: Starting X coordinate
382
+ start_y: Starting Y coordinate
383
+ end_x: Ending X coordinate
384
+ end_y: Ending Y coordinate
385
+ duration: Duration of swipe in seconds
386
+ Returns:
387
+ Bool indicating success or failure
388
+ """
389
+ try:
390
+ logger.debug(
391
+ f"Dragging from ({start_x}, {start_y}) to ({end_x}, {end_y}) in {duration} seconds"
392
+ )
393
+ self.device.drag(start_x, start_y, end_x, end_y, duration)
394
+
395
+ if self._ctx:
396
+ drag_event = DragActionEvent(
397
+ action_type="drag",
398
+ description=f"Drag from ({start_x}, {start_y}) to ({end_x}, {end_y}) in {duration} seconds",
399
+ start_x=start_x,
400
+ start_y=start_y,
401
+ end_x=end_x,
402
+ end_y=end_y,
403
+ duration=duration,
404
+ )
405
+ self._ctx.write_event_to_stream(drag_event)
406
+
407
+ time.sleep(duration)
408
+ logger.debug(
409
+ f"Dragged from ({start_x}, {start_y}) to ({end_x}, {end_y}) in {duration} seconds"
410
+ )
300
411
  return True
301
412
  except ValueError as e:
302
413
  print(f"Error: {str(e)}")
303
414
  return False
304
415
 
305
- async def input_text(self, text: str, serial: Optional[str] = None) -> str:
416
+ def input_text(self, text: str) -> str:
306
417
  """
307
418
  Input text on the device.
308
419
  Always make sure that the Focused Element is not None before inputting text.
@@ -314,72 +425,105 @@ class AdbTools(Tools):
314
425
  Result message
315
426
  """
316
427
  try:
317
- if serial:
318
- device = await self.device_manager.get_device(serial)
319
- if not device:
320
- return f"Error: Device {serial} not found"
321
- else:
322
- device = await self.get_device()
323
-
324
- # Save the current keyboard
325
- original_ime = await device._adb.shell(
326
- device._serial, "settings get secure default_input_method"
327
- )
328
- original_ime = original_ime.strip()
329
-
330
- # Enable the Droidrun keyboard
331
- await device._adb.shell(
332
- device._serial, "ime enable com.droidrun.portal/.DroidrunKeyboardIME"
333
- )
334
-
335
- # Set the Droidrun keyboard as the default
336
- await device._adb.shell(
337
- device._serial, "ime set com.droidrun.portal/.DroidrunKeyboardIME"
338
- )
428
+ logger.debug(f"Inputting text: {text}")
339
429
 
340
- # Wait for keyboard to change
341
- await asyncio.sleep(1)
430
+ if self.use_tcp and self.tcp_forwarded:
431
+ # Use TCP communication
432
+ encoded_text = base64.b64encode(text.encode()).decode()
342
433
 
343
- # Encode the text to Base64
344
- import base64
434
+ payload = {"base64_text": encoded_text}
435
+ response = requests.post(
436
+ f"{self.tcp_base_url}/keyboard/input",
437
+ json=payload,
438
+ headers={"Content-Type": "application/json"},
439
+ timeout=10,
440
+ )
345
441
 
346
- encoded_text = base64.b64encode(text.encode()).decode()
442
+ logger.debug(
443
+ f"Keyboard input TCP response: {response.status_code}, {response.text}"
444
+ )
347
445
 
348
- cmd = f'content insert --uri "content://com.droidrun.portal/keyboard/input" --bind base64_text:s:"{encoded_text}"'
349
- await device._adb.shell(device._serial, cmd)
350
-
351
- # Wait for text input to complete
352
- await asyncio.sleep(0.5)
353
-
354
- # Restore the original keyboard
355
- if original_ime and "com.droidrun.portal" not in original_ime:
356
- await device._adb.shell(device._serial, f"ime set {original_ime}")
446
+ if response.status_code != 200:
447
+ return f"Error: HTTP request failed with status {response.status_code}: {response.text}"
357
448
 
449
+ else:
450
+ # Fallback to content provider method
451
+ # Save the current keyboard
452
+ original_ime = self.device.shell(
453
+ "settings get secure default_input_method"
454
+ )
455
+ original_ime = original_ime.strip()
456
+
457
+ # Enable the Droidrun keyboard
458
+ self.device.shell("ime enable com.droidrun.portal/.DroidrunKeyboardIME")
459
+
460
+ # Set the Droidrun keyboard as the default
461
+ self.device.shell("ime set com.droidrun.portal/.DroidrunKeyboardIME")
462
+
463
+ # Wait for keyboard to change
464
+ time.sleep(1)
465
+
466
+ # Encode the text to Base64
467
+ encoded_text = base64.b64encode(text.encode()).decode()
468
+
469
+ cmd = f'content insert --uri "content://com.droidrun.portal/keyboard/input" --bind base64_text:s:"{encoded_text}"'
470
+ self.device.shell(cmd)
471
+
472
+ # Wait for text input to complete
473
+ time.sleep(0.5)
474
+
475
+ # Restore the original keyboard
476
+ if original_ime and "com.droidrun.portal" not in original_ime:
477
+ self.device.shell(f"ime set {original_ime}")
478
+
479
+ logger.debug(
480
+ f"Text input completed: {text[:50]}{'...' if len(text) > 50 else ''}"
481
+ )
482
+ return f"Text input completed: {text[:50]}{'...' if len(text) > 50 else ''}"
483
+
484
+ if self._ctx:
485
+ input_event = InputTextActionEvent(
486
+ action_type="input_text",
487
+ description=f"Input text: '{text[:50]}{'...' if len(text) > 50 else ''}'",
488
+ text=text,
489
+ )
490
+ self._ctx.write_event_to_stream(input_event)
491
+
492
+ logger.debug(
493
+ f"Text input completed: {text[:50]}{'...' if len(text) > 50 else ''}"
494
+ )
358
495
  return f"Text input completed: {text[:50]}{'...' if len(text) > 50 else ''}"
496
+
497
+ except requests.exceptions.RequestException as e:
498
+ return f"Error: TCP request failed: {str(e)}"
359
499
  except ValueError as e:
360
500
  return f"Error: {str(e)}"
361
501
  except Exception as e:
362
502
  return f"Error sending text input: {str(e)}"
363
503
 
364
- async def back(self) -> str:
504
+ def back(self) -> str:
365
505
  """
366
506
  Go back on the current view.
367
507
  This presses the Android back button.
368
508
  """
369
509
  try:
370
- if self.serial:
371
- device = await self.device_manager.get_device(self.serial)
372
- if not device:
373
- return f"Error: Device {self.serial} not found"
374
- else:
375
- device = await self.get_device()
510
+ logger.debug("Pressing key BACK")
511
+ self.device.keyevent(3)
512
+
513
+ if self._ctx:
514
+ key_event = KeyPressActionEvent(
515
+ action_type="key_press",
516
+ description=f"Pressed key BACK",
517
+ keycode=3,
518
+ key_name="BACK",
519
+ )
520
+ self._ctx.write_event_to_stream(key_event)
376
521
 
377
- await device.press_key(3)
378
522
  return f"Pressed key BACK"
379
523
  except ValueError as e:
380
524
  return f"Error: {str(e)}"
381
525
 
382
- async def press_key(self, keycode: int) -> str:
526
+ def press_key(self, keycode: int) -> str:
383
527
  """
384
528
  Press a key on the Android device.
385
529
 
@@ -393,13 +537,6 @@ class AdbTools(Tools):
393
537
  keycode: Android keycode to press
394
538
  """
395
539
  try:
396
- if self.serial:
397
- device = await self.device_manager.get_device(self.serial)
398
- if not device:
399
- return f"Error: Device {self.serial} not found"
400
- else:
401
- device = await self.get_device()
402
-
403
540
  key_names = {
404
541
  66: "ENTER",
405
542
  4: "BACK",
@@ -408,12 +545,23 @@ class AdbTools(Tools):
408
545
  }
409
546
  key_name = key_names.get(keycode, str(keycode))
410
547
 
411
- await device.press_key(keycode)
548
+ if self._ctx:
549
+ key_event = KeyPressActionEvent(
550
+ action_type="key_press",
551
+ description=f"Pressed key {key_name}",
552
+ keycode=keycode,
553
+ key_name=key_name,
554
+ )
555
+ self._ctx.write_event_to_stream(key_event)
556
+
557
+ logger.debug(f"Pressing key {key_name}")
558
+ self.device.keyevent(keycode)
559
+ logger.debug(f"Pressed key {key_name}")
412
560
  return f"Pressed key {key_name}"
413
561
  except ValueError as e:
414
562
  return f"Error: {str(e)}"
415
563
 
416
- async def start_app(self, package: str, activity: str = "") -> str:
564
+ def start_app(self, package: str, activity: str | None = None) -> str:
417
565
  """
418
566
  Start an app on the device.
419
567
 
@@ -422,19 +570,32 @@ class AdbTools(Tools):
422
570
  activity: Optional activity name
423
571
  """
424
572
  try:
425
- if self.serial:
426
- device = await self.device_manager.get_device(self.serial)
427
- if not device:
428
- return f"Error: Device {self.serial} not found"
429
- else:
430
- device = await self.get_device()
431
573
 
432
- result = await device.start_app(package, activity)
433
- return result
434
- except ValueError as e:
574
+ logger.debug(f"Starting app {package} with activity {activity}")
575
+ if not activity:
576
+ dumpsys_output = self.device.shell(
577
+ f"cmd package resolve-activity --brief {package}"
578
+ )
579
+ activity = dumpsys_output.splitlines()[1].split("/")[1]
580
+
581
+ if self._ctx:
582
+ start_app_event = StartAppEvent(
583
+ action_type="start_app",
584
+ description=f"Start app {package}",
585
+ package=package,
586
+ activity=activity,
587
+ )
588
+ self._ctx.write_event_to_stream(start_app_event)
589
+
590
+ print(f"Activity: {activity}")
591
+
592
+ self.device.app_start(package, activity)
593
+ logger.debug(f"App started: {package} with activity {activity}")
594
+ return f"App started: {package} with activity {activity}"
595
+ except Exception as e:
435
596
  return f"Error: {str(e)}"
436
597
 
437
- async def install_app(
598
+ def install_app(
438
599
  self, apk_path: str, reinstall: bool = False, grant_permissions: bool = True
439
600
  ) -> str:
440
601
  """
@@ -446,50 +607,94 @@ class AdbTools(Tools):
446
607
  grant_permissions: Whether to grant all permissions
447
608
  """
448
609
  try:
449
- if self.serial:
450
- device = await self.device_manager.get_device(self.serial)
451
- if not device:
452
- return f"Error: Device {self.serial} not found"
453
- else:
454
- device = await self.get_device()
455
-
456
610
  if not os.path.exists(apk_path):
457
611
  return f"Error: APK file not found at {apk_path}"
458
612
 
459
- result = await device.install_app(apk_path, reinstall, grant_permissions)
613
+ logger.debug(
614
+ f"Installing app: {apk_path} with reinstall: {reinstall} and grant_permissions: {grant_permissions}"
615
+ )
616
+ result = self.device.install(
617
+ apk_path,
618
+ nolaunch=True,
619
+ uninstall=reinstall,
620
+ flags=["-g"] if grant_permissions else [],
621
+ silent=True,
622
+ )
623
+ logger.debug(f"Installed app: {apk_path} with result: {result}")
460
624
  return result
461
625
  except ValueError as e:
462
626
  return f"Error: {str(e)}"
463
627
 
464
- async def take_screenshot(self) -> Tuple[str, bytes]:
628
+ def take_screenshot(self) -> Tuple[str, bytes]:
465
629
  """
466
630
  Take a screenshot of the device.
467
631
  This function captures the current screen and adds the screenshot to context in the next message.
468
632
  Also stores the screenshot in the screenshots list with timestamp for later GIF creation.
469
633
  """
470
634
  try:
471
- if self.serial:
472
- device = await self.device_manager.get_device(self.serial)
473
- if not device:
474
- raise ValueError(f"Device {self.serial} not found")
635
+ logger.debug("Taking screenshot")
636
+
637
+ if self.use_tcp and self.tcp_forwarded:
638
+ # Use TCP communication
639
+ response = requests.get(f"{self.tcp_base_url}/screenshot", timeout=15)
640
+
641
+ if response.status_code == 200:
642
+ tcp_response = response.json()
643
+
644
+ # Check if response has the expected format with data field
645
+ if isinstance(tcp_response, dict) and "data" in tcp_response:
646
+ base64_data = tcp_response["data"]
647
+ try:
648
+ # Decode base64 to get image bytes
649
+ image_bytes = base64.b64decode(base64_data)
650
+ img_format = "PNG" # Assuming PNG format from TCP endpoint
651
+ logger.debug("Screenshot taken via TCP")
652
+ except Exception as e:
653
+ raise ValueError(
654
+ f"Failed to decode base64 screenshot data: {str(e)}"
655
+ )
656
+ else:
657
+ # Fallback: assume direct base64 format
658
+ try:
659
+ image_bytes = base64.b64decode(tcp_response)
660
+ img_format = "PNG"
661
+ logger.debug("Screenshot taken via TCP (direct base64)")
662
+ except Exception as e:
663
+ raise ValueError(
664
+ f"Failed to decode screenshot response: {str(e)}"
665
+ )
666
+ else:
667
+ raise ValueError(
668
+ f"HTTP request failed with status {response.status_code}: {response.text}"
669
+ )
670
+
475
671
  else:
476
- device = await self.get_device()
477
- screen_tuple = await device.take_screenshot()
478
- self.last_screenshot = screen_tuple[1]
672
+ # Fallback to ADB screenshot method
673
+ img = self.device.screenshot()
674
+ img_buf = io.BytesIO()
675
+ img_format = "PNG"
676
+ img.save(img_buf, format=img_format)
677
+ image_bytes = img_buf.getvalue()
678
+ logger.debug("Screenshot taken via ADB")
479
679
 
480
680
  # Store screenshot with timestamp
481
681
  self.screenshots.append(
482
682
  {
483
683
  "timestamp": time.time(),
484
- "image_data": screen_tuple[1],
485
- "format": screen_tuple[0], # Usually 'PNG'
684
+ "image_data": image_bytes,
685
+ "format": img_format,
486
686
  }
487
687
  )
488
- return screen_tuple
688
+ return img_format, image_bytes
689
+
690
+ except requests.exceptions.RequestException as e:
691
+ raise ValueError(f"Error taking screenshot via TCP: {str(e)}")
489
692
  except ValueError as e:
490
693
  raise ValueError(f"Error taking screenshot: {str(e)}")
694
+ except Exception as e:
695
+ raise ValueError(f"Unexpected error taking screenshot: {str(e)}")
491
696
 
492
- async def list_packages(self, include_system_apps: bool = False) -> List[str]:
697
+ def list_packages(self, include_system_apps: bool = False) -> List[str]:
493
698
  """
494
699
  List installed packages on the device.
495
700
 
@@ -500,163 +705,11 @@ class AdbTools(Tools):
500
705
  List of package names
501
706
  """
502
707
  try:
503
- if self.serial:
504
- device = await self.device_manager.get_device(self.serial)
505
- if not device:
506
- raise ValueError(f"Device {self.serial} not found")
507
- else:
508
- device = await self.get_device()
509
-
510
- # Use the direct ADB command to get packages with paths
511
- cmd = ["pm", "list", "packages", "-f"]
512
- if not include_system_apps:
513
- cmd.append("-3")
514
-
515
- output = await device._adb.shell(device._serial, " ".join(cmd))
516
-
517
- # Parse the package list using the function
518
- packages = self.parse_package_list(output)
519
- # Format package list for better readability
520
- package_list = [pack["package"] for pack in packages]
521
- for package in package_list:
522
- print(package)
523
- return package_list
708
+ logger.debug("Listing packages")
709
+ return self.device.list_packages(["-3"] if not include_system_apps else [])
524
710
  except ValueError as e:
525
711
  raise ValueError(f"Error listing packages: {str(e)}")
526
712
 
527
- async def extract(self, filename: Optional[str] = None) -> str:
528
- """Extract and save the current UI state to a JSON file.
529
-
530
- This function captures the current UI state including all UI elements
531
- and saves it to a JSON file for later analysis or reference.
532
-
533
- Args:
534
- filename: Optional filename to save the UI state (defaults to ui_state_TIMESTAMP.json)
535
-
536
- Returns:
537
- Path to the saved JSON file
538
- """
539
- try:
540
- # Generate default filename if not provided
541
- if not filename:
542
- timestamp = int(time.time())
543
- filename = f"ui_state_{timestamp}.json"
544
-
545
- # Ensure the filename ends with .json
546
- if not filename.endswith(".json"):
547
- filename += ".json"
548
-
549
- # Get the UI elements
550
- ui_elements = await self.get_all_elements(self.serial)
551
-
552
- # Save to file
553
- save_path = os.path.abspath(filename)
554
- async with aiofiles.open(save_path, "w", encoding="utf-8") as f:
555
- await f.write(json.dumps(ui_elements, indent=2))
556
-
557
- return f"UI state extracted and saved to {save_path}"
558
-
559
- except Exception as e:
560
- return f"Error extracting UI state: {e}"
561
-
562
- async def get_all_elements(self) -> Dict[str, Any]:
563
- """
564
- Get all UI elements from the device, including non-interactive elements.
565
-
566
- This function interacts with the TopViewService app installed on the device
567
- to capture all UI elements, even those that are not interactive. This provides
568
- a complete view of the UI hierarchy for analysis or debugging purposes.
569
-
570
- Returns:
571
- Dictionary containing all UI elements extracted from the device screen
572
- """
573
- try:
574
- # Get the device
575
- device = await self.device_manager.get_device(self.serial)
576
- if not device:
577
- raise ValueError(f"Device {self.serial} not found")
578
-
579
- # Create a temporary file for the JSON
580
- with tempfile.NamedTemporaryFile(suffix=".json") as temp:
581
- local_path = temp.name
582
-
583
- try:
584
- # Clear logcat to make it easier to find our output
585
- await device._adb.shell(device._serial, "logcat -c")
586
-
587
- # Trigger the custom service via broadcast to get ALL elements
588
- await device._adb.shell(
589
- device._serial,
590
- "am broadcast -a com.droidrun.portal.GET_ALL_ELEMENTS",
591
- )
592
-
593
- # Poll for the JSON file path
594
- start_time = asyncio.get_event_loop().time()
595
- max_wait_time = 10 # Maximum wait time in seconds
596
- poll_interval = 0.2 # Check every 200ms
597
-
598
- device_path = None
599
- while asyncio.get_event_loop().time() - start_time < max_wait_time:
600
- # Check logcat for the file path
601
- logcat_output = await device._adb.shell(
602
- device._serial,
603
- 'logcat -d | grep "DROIDRUN_FILE" | grep "JSON data written to" | tail -1',
604
- )
605
-
606
- # Parse the file path if present
607
- match = re.search(r"JSON data written to: (.*)", logcat_output)
608
- if match:
609
- device_path = match.group(1).strip()
610
- break
611
-
612
- # Wait before polling again
613
- await asyncio.sleep(poll_interval)
614
-
615
- # Check if we found the file path
616
- if not device_path:
617
- raise ValueError(
618
- f"Failed to find the JSON file path in logcat after {max_wait_time} seconds"
619
- )
620
-
621
- logger.debug(f"Pulling file from {device_path} to {local_path}")
622
- # Pull the JSON file from the device
623
- await device._adb.pull_file(device._serial, device_path, local_path)
624
-
625
- # Read the JSON file
626
- async with aiofiles.open(local_path, "r", encoding="utf-8") as f:
627
- json_content = await f.read()
628
-
629
- # Clean up the temporary file
630
- with contextlib.suppress(OSError):
631
- os.unlink(local_path)
632
-
633
- # Try to parse the JSON
634
- import json
635
-
636
- try:
637
- ui_data = json.loads(json_content)
638
-
639
- return {
640
- "all_elements": ui_data,
641
- "count": (
642
- len(ui_data)
643
- if isinstance(ui_data, list)
644
- else sum(1 for _ in ui_data.get("elements", []))
645
- ),
646
- "message": "Retrieved all UI elements from the device screen",
647
- }
648
- except json.JSONDecodeError:
649
- raise ValueError("Failed to parse UI elements JSON data")
650
-
651
- except Exception as e:
652
- # Clean up in case of error
653
- with contextlib.suppress(OSError):
654
- os.unlink(local_path)
655
- raise ValueError(f"Error retrieving all UI elements: {e}")
656
-
657
- except Exception as e:
658
- raise ValueError(f"Error getting all UI elements: {e}")
659
-
660
713
  def complete(self, success: bool, reason: str = ""):
661
714
  """
662
715
  Mark the task as finished.
@@ -676,7 +729,7 @@ class AdbTools(Tools):
676
729
  self.reason = reason
677
730
  self.finished = True
678
731
 
679
- async def remember(self, information: str) -> str:
732
+ def remember(self, information: str) -> str:
680
733
  """
681
734
  Store important information to remember for future context.
682
735
 
@@ -712,7 +765,7 @@ class AdbTools(Tools):
712
765
  """
713
766
  return self.memory.copy()
714
767
 
715
- async def get_state(self, serial: Optional[str] = None) -> Dict[str, Any]:
768
+ def get_state(self, serial: Optional[str] = None) -> Dict[str, Any]:
716
769
  """
717
770
  Get both the a11y tree and phone state in a single call using the combined /state endpoint.
718
771
 
@@ -722,54 +775,75 @@ class AdbTools(Tools):
722
775
  Returns:
723
776
  Dictionary containing both 'a11y_tree' and 'phone_state' data
724
777
  """
725
-
778
+
726
779
  try:
727
- if serial:
728
- device = await self.device_manager.get_device(serial)
729
- if not device:
730
- raise ValueError(f"Device {serial} not found")
780
+ logger.debug("Getting state")
781
+
782
+ if self.use_tcp and self.tcp_forwarded:
783
+ # Use TCP communication
784
+ response = requests.get(f"{self.tcp_base_url}/state", timeout=10)
785
+
786
+ if response.status_code == 200:
787
+ tcp_response = response.json()
788
+
789
+ # Check if response has the expected format
790
+ if isinstance(tcp_response, dict) and "data" in tcp_response:
791
+ data_str = tcp_response["data"]
792
+ try:
793
+ combined_data = json.loads(data_str)
794
+ except json.JSONDecodeError:
795
+ return {
796
+ "error": "Parse Error",
797
+ "message": "Failed to parse JSON data from TCP response data field",
798
+ }
799
+ else:
800
+ # Fallback: assume direct JSON format
801
+ combined_data = tcp_response
802
+ else:
803
+ return {
804
+ "error": "HTTP Error",
805
+ "message": f"HTTP request failed with status {response.status_code}",
806
+ }
731
807
  else:
732
- device = await self.get_device()
733
-
734
- adb_output = await device._adb.shell(
735
- device._serial,
736
- 'content query --uri content://com.droidrun.portal/state'
737
- )
808
+ # Fallback to content provider method
809
+ adb_output = self.device.shell(
810
+ "content query --uri content://com.droidrun.portal/state",
811
+ )
738
812
 
739
- state_data = self._parse_content_provider_output(adb_output)
740
-
741
- if state_data is None:
742
- return {
743
- "error": "Parse Error",
744
- "message": "Failed to parse state data from ContentProvider response"
745
- }
813
+ state_data = self._parse_content_provider_output(adb_output)
746
814
 
747
- if isinstance(state_data, dict) and "data" in state_data:
748
- data_str = state_data["data"]
749
- try:
750
- combined_data = json.loads(data_str)
751
- except json.JSONDecodeError:
815
+ if state_data is None:
752
816
  return {
753
817
  "error": "Parse Error",
754
- "message": "Failed to parse JSON data from ContentProvider data field"
818
+ "message": "Failed to parse state data from ContentProvider response",
819
+ }
820
+
821
+ if isinstance(state_data, dict) and "data" in state_data:
822
+ data_str = state_data["data"]
823
+ try:
824
+ combined_data = json.loads(data_str)
825
+ except json.JSONDecodeError:
826
+ return {
827
+ "error": "Parse Error",
828
+ "message": "Failed to parse JSON data from ContentProvider data field",
829
+ }
830
+ else:
831
+ return {
832
+ "error": "Format Error",
833
+ "message": f"Unexpected state data format: {type(state_data)}",
755
834
  }
756
- else:
757
- return {
758
- "error": "Format Error",
759
- "message": f"Unexpected state data format: {type(state_data)}"
760
- }
761
835
 
762
836
  # Validate that both a11y_tree and phone_state are present
763
837
  if "a11y_tree" not in combined_data:
764
838
  return {
765
839
  "error": "Missing Data",
766
- "message": "a11y_tree not found in combined state data"
840
+ "message": "a11y_tree not found in combined state data",
767
841
  }
768
-
842
+
769
843
  if "phone_state" not in combined_data:
770
844
  return {
771
- "error": "Missing Data",
772
- "message": "phone_state not found in combined state data"
845
+ "error": "Missing Data",
846
+ "message": "phone_state not found in combined state data",
773
847
  }
774
848
 
775
849
  # Filter out the "type" attribute from all a11y_tree elements
@@ -777,9 +851,7 @@ class AdbTools(Tools):
777
851
  filtered_elements = []
778
852
  for element in elements:
779
853
  # Create a copy of the element without the "type" attribute
780
- filtered_element = {
781
- k: v for k, v in element.items() if k != "type"
782
- }
854
+ filtered_element = {k: v for k, v in element.items() if k != "type"}
783
855
 
784
856
  # Also filter children if present
785
857
  if "children" in filtered_element:
@@ -789,19 +861,279 @@ class AdbTools(Tools):
789
861
  ]
790
862
 
791
863
  filtered_elements.append(filtered_element)
792
-
864
+
793
865
  self.clickable_elements_cache = filtered_elements
794
-
866
+
795
867
  return {
796
868
  "a11y_tree": filtered_elements,
797
- "phone_state": combined_data["phone_state"]
869
+ "phone_state": combined_data["phone_state"],
798
870
  }
799
871
 
872
+ except requests.exceptions.RequestException as e:
873
+ return {
874
+ "error": "TCP Error",
875
+ "message": f"TCP request failed: {str(e)}",
876
+ }
800
877
  except Exception as e:
801
- return {"error": str(e), "message": f"Error getting combined state: {str(e)}"}
878
+ return {
879
+ "error": str(e),
880
+ "message": f"Error getting combined state: {str(e)}",
881
+ }
802
882
 
803
- if __name__ == "__main__":
804
- async def main():
805
- tools = AdbTools()
883
+ def get_a11y_tree(self) -> Dict[str, Any]:
884
+ """
885
+ Get just the accessibility tree using the /a11y_tree endpoint.
886
+
887
+ Returns:
888
+ Dictionary containing accessibility tree data
889
+ """
890
+ try:
891
+ if self.use_tcp and self.tcp_forwarded:
892
+ response = requests.get(f"{self.tcp_base_url}/a11y_tree", timeout=10)
893
+
894
+ if response.status_code == 200:
895
+ tcp_response = response.json()
896
+
897
+ # Check if response has the expected format with data field
898
+ if isinstance(tcp_response, dict) and "data" in tcp_response:
899
+ data_str = tcp_response["data"]
900
+ try:
901
+ return json.loads(data_str)
902
+ except json.JSONDecodeError:
903
+ return {
904
+ "error": "Parse Error",
905
+ "message": "Failed to parse JSON data from TCP response data field",
906
+ }
907
+ else:
908
+ # Fallback: assume direct JSON format
909
+ return tcp_response
910
+ else:
911
+ return {
912
+ "error": "HTTP Error",
913
+ "message": f"HTTP request failed with status {response.status_code}",
914
+ }
915
+ else:
916
+ # Fallback: use get_state and extract a11y_tree
917
+ state = self.get_state()
918
+ if "error" in state:
919
+ return state
920
+ return {"a11y_tree": state.get("a11y_tree", [])}
921
+
922
+ except requests.exceptions.RequestException as e:
923
+ return {
924
+ "error": "TCP Error",
925
+ "message": f"TCP request failed: {str(e)}",
926
+ }
927
+ except Exception as e:
928
+ return {
929
+ "error": str(e),
930
+ "message": f"Error getting a11y tree: {str(e)}",
931
+ }
932
+
933
+ def get_phone_state(self) -> Dict[str, Any]:
934
+ """
935
+ Get just the phone state using the /phone_state endpoint.
936
+
937
+ Returns:
938
+ Dictionary containing phone state data
939
+ """
940
+ try:
941
+ if self.use_tcp and self.tcp_forwarded:
942
+ response = requests.get(f"{self.tcp_base_url}/phone_state", timeout=10)
943
+
944
+ if response.status_code == 200:
945
+ tcp_response = response.json()
946
+
947
+ # Check if response has the expected format with data field
948
+ if isinstance(tcp_response, dict) and "data" in tcp_response:
949
+ data_str = tcp_response["data"]
950
+ try:
951
+ return json.loads(data_str)
952
+ except json.JSONDecodeError:
953
+ return {
954
+ "error": "Parse Error",
955
+ "message": "Failed to parse JSON data from TCP response data field",
956
+ }
957
+ else:
958
+ # Fallback: assume direct JSON format
959
+ return tcp_response
960
+ else:
961
+ return {
962
+ "error": "HTTP Error",
963
+ "message": f"HTTP request failed with status {response.status_code}",
964
+ }
965
+ else:
966
+ # Fallback: use get_state and extract phone_state
967
+ state = self.get_state()
968
+ if "error" in state:
969
+ return state
970
+ return {"phone_state": state.get("phone_state", {})}
971
+
972
+ except requests.exceptions.RequestException as e:
973
+ return {
974
+ "error": "TCP Error",
975
+ "message": f"TCP request failed: {str(e)}",
976
+ }
977
+ except Exception as e:
978
+ return {
979
+ "error": str(e),
980
+ "message": f"Error getting phone state: {str(e)}",
981
+ }
982
+
983
+ def ping(self) -> Dict[str, Any]:
984
+ """
985
+ Test the TCP connection using the /ping endpoint.
806
986
 
807
- asyncio.run(main())
987
+ Returns:
988
+ Dictionary with ping result
989
+ """
990
+ try:
991
+ if self.use_tcp and self.tcp_forwarded:
992
+ response = requests.get(f"{self.tcp_base_url}/ping", timeout=5)
993
+
994
+ if response.status_code == 200:
995
+ try:
996
+ tcp_response = response.json() if response.content else {}
997
+ logger.debug(f"Ping TCP response: {tcp_response}")
998
+ return {
999
+ "status": "success",
1000
+ "message": "Ping successful",
1001
+ "response": tcp_response,
1002
+ }
1003
+ except json.JSONDecodeError:
1004
+ return {
1005
+ "status": "success",
1006
+ "message": "Ping successful (non-JSON response)",
1007
+ "response": response.text,
1008
+ }
1009
+ else:
1010
+ return {
1011
+ "status": "error",
1012
+ "message": f"Ping failed with status {response.status_code}: {response.text}",
1013
+ }
1014
+ else:
1015
+ return {
1016
+ "status": "error",
1017
+ "message": "TCP communication is not enabled",
1018
+ }
1019
+
1020
+ except requests.exceptions.RequestException as e:
1021
+ return {
1022
+ "status": "error",
1023
+ "message": f"Ping failed: {str(e)}",
1024
+ }
1025
+ except Exception as e:
1026
+ return {
1027
+ "status": "error",
1028
+ "message": f"Error during ping: {str(e)}",
1029
+ }
1030
+
1031
+
1032
+ def _shell_test_cli(serial: str, command: str) -> tuple[str, float]:
1033
+ """
1034
+ Run an adb shell command using the adb CLI and measure execution time.
1035
+ Args:
1036
+ serial: Device serial number
1037
+ command: Shell command to run
1038
+ Returns:
1039
+ Tuple of (output, elapsed_time)
1040
+ """
1041
+ import time
1042
+ import subprocess
1043
+
1044
+ adb_cmd = ["adb", "-s", serial, "shell", command]
1045
+ start = time.perf_counter()
1046
+ result = subprocess.run(adb_cmd, capture_output=True, text=True)
1047
+ elapsed = time.perf_counter() - start
1048
+ output = result.stdout.strip() if result.returncode == 0 else result.stderr.strip()
1049
+ return output, elapsed
1050
+
1051
+
1052
+ def _shell_test():
1053
+ device = adb.device("emulator-5554")
1054
+ # Native Python adb client
1055
+ start = time.time()
1056
+ res = device.shell("echo 'Hello, World!'")
1057
+ end = time.time()
1058
+ print(f"[Native] Shell execution took {end - start:.3f} seconds: {res}")
1059
+
1060
+ start = time.time()
1061
+ res = device.shell("content query --uri content://com.droidrun.portal/state")
1062
+ end = time.time()
1063
+ print(f"[Native] Shell execution took {end - start:.3f} seconds: phone_state")
1064
+
1065
+ # CLI version
1066
+ output, elapsed = _shell_test_cli("emulator-5554", "echo 'Hello, World!'")
1067
+ print(f"[CLI] Shell execution took {elapsed:.3f} seconds: {output}")
1068
+
1069
+ output, elapsed = _shell_test_cli(
1070
+ "emulator-5554", "content query --uri content://com.droidrun.portal/state"
1071
+ )
1072
+ print(f"[CLI] Shell execution took {elapsed:.3f} seconds: phone_state")
1073
+
1074
+
1075
+ def _list_packages():
1076
+ tools = AdbTools()
1077
+ print(tools.list_packages())
1078
+
1079
+
1080
+ def _start_app():
1081
+ tools = AdbTools()
1082
+ tools.start_app("com.android.settings", ".Settings")
1083
+
1084
+
1085
+ def _shell_test_cli(serial: str, command: str) -> tuple[str, float]:
1086
+ """
1087
+ Run an adb shell command using the adb CLI and measure execution time.
1088
+ Args:
1089
+ serial: Device serial number
1090
+ command: Shell command to run
1091
+ Returns:
1092
+ Tuple of (output, elapsed_time)
1093
+ """
1094
+ import time
1095
+ import subprocess
1096
+
1097
+ adb_cmd = ["adb", "-s", serial, "shell", command]
1098
+ start = time.perf_counter()
1099
+ result = subprocess.run(adb_cmd, capture_output=True, text=True)
1100
+ elapsed = time.perf_counter() - start
1101
+ output = result.stdout.strip() if result.returncode == 0 else result.stderr.strip()
1102
+ return output, elapsed
1103
+
1104
+
1105
+ def _shell_test():
1106
+ device = adb.device("emulator-5554")
1107
+ # Native Python adb client
1108
+ start = time.time()
1109
+ res = device.shell("echo 'Hello, World!'")
1110
+ end = time.time()
1111
+ print(f"[Native] Shell execution took {end - start:.3f} seconds: {res}")
1112
+
1113
+ start = time.time()
1114
+ res = device.shell("content query --uri content://com.droidrun.portal/state")
1115
+ end = time.time()
1116
+ print(f"[Native] Shell execution took {end - start:.3f} seconds: phone_state")
1117
+
1118
+ # CLI version
1119
+ output, elapsed = _shell_test_cli("emulator-5554", "echo 'Hello, World!'")
1120
+ print(f"[CLI] Shell execution took {elapsed:.3f} seconds: {output}")
1121
+
1122
+ output, elapsed = _shell_test_cli(
1123
+ "emulator-5554", "content query --uri content://com.droidrun.portal/state"
1124
+ )
1125
+ print(f"[CLI] Shell execution took {elapsed:.3f} seconds: phone_state")
1126
+
1127
+
1128
+ def _list_packages():
1129
+ tools = AdbTools()
1130
+ print(tools.list_packages())
1131
+
1132
+
1133
+ def _start_app():
1134
+ tools = AdbTools()
1135
+ tools.start_app("com.android.settings", ".Settings")
1136
+
1137
+
1138
+ if __name__ == "__main__":
1139
+ _start_app()