droidrun 0.3.1__py3-none-any.whl → 0.3.2__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,31 @@ UI Actions - Core UI interaction tools for Android device control.
3
3
  """
4
4
 
5
5
  import os
6
- import re
7
6
  import json
8
7
  import time
9
- import tempfile
10
8
  import asyncio
11
- import aiofiles
12
- import contextlib
13
9
  import logging
14
- from typing import Optional, Dict, Tuple, List, Any
10
+ from typing import Optional, Dict, Tuple, List, Any, Type, Self
15
11
  from droidrun.adb.device import Device
16
12
  from droidrun.adb.manager import DeviceManager
17
13
  from droidrun.tools.tools import Tools
18
14
 
19
15
  logger = logging.getLogger("droidrun-adb-tools")
20
16
 
17
+
21
18
  class AdbTools(Tools):
22
19
  """Core UI interaction tools for Android device control."""
23
20
 
24
- def __init__(self, serial: str = "emulator-5554") -> None:
21
+ def __init__(self, serial: str) -> None:
22
+ """Initialize the AdbTools instance.
23
+
24
+ Args:
25
+ serial: Device serial number
26
+ """
27
+ self.device_manager = DeviceManager()
25
28
  # Instance‐level cache for clickable elements (index-based tapping)
26
29
  self.clickable_elements_cache: List[Dict[str, Any]] = []
27
30
  self.serial = serial
28
- self.device_manager = DeviceManager()
29
31
  self.last_screenshot = None
30
32
  self.reason = None
31
33
  self.success = None
@@ -35,19 +37,38 @@ class AdbTools(Tools):
35
37
  # Store all screenshots with timestamps
36
38
  self.screenshots: List[Dict[str, Any]] = []
37
39
 
38
- def get_device_serial(self) -> str:
40
+ @classmethod
41
+ async def create(cls: Type[Self], serial: str = None) -> Self:
42
+ """Create an AdbTools instance.
43
+
44
+ Args:
45
+ serial: Optional device serial number. If not provided, the first device found will be used.
46
+
47
+ Returns:
48
+ AdbTools instance
49
+ """
50
+ if not serial:
51
+ dvm = DeviceManager()
52
+ devices = await dvm.list_devices()
53
+ if not devices or len(devices) < 1:
54
+ raise ValueError("No devices found")
55
+ serial = devices[0].serial
56
+
57
+ return AdbTools(serial)
58
+
59
+ def _get_device_serial(self) -> str:
39
60
  """Get the device serial from the instance or environment variable."""
40
61
  # First try using the instance's serial
41
62
  if self.serial:
42
63
  return self.serial
43
64
 
44
- async def get_device(self) -> Optional[Device]:
65
+ async def _get_device(self) -> Optional[Device]:
45
66
  """Get the device instance using the instance's serial or from environment variable.
46
67
 
47
68
  Returns:
48
69
  Device instance or None if not found
49
70
  """
50
- serial = self.get_device_serial()
71
+ serial = self._get_device_serial()
51
72
  if not serial:
52
73
  raise ValueError("No device serial specified - set device_serial parameter")
53
74
 
@@ -57,63 +78,46 @@ class AdbTools(Tools):
57
78
 
58
79
  return device
59
80
 
60
- def parse_package_list(self, output: str) -> List[Dict[str, str]]:
61
- """Parse the output of 'pm list packages -f' command.
62
-
63
- Args:
64
- output: Raw command output from 'pm list packages -f'
65
-
66
- Returns:
67
- List of dictionaries containing package info with 'package' and 'path' keys
68
- """
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
78
-
79
- def _parse_content_provider_output(self, raw_output: str) -> Optional[Dict[str, Any]]:
81
+ def _parse_content_provider_output(
82
+ self, raw_output: str
83
+ ) -> Optional[Dict[str, Any]]:
80
84
  """
81
85
  Parse the raw ADB content provider output and extract JSON data.
82
-
86
+
83
87
  Args:
84
88
  raw_output (str): Raw output from ADB content query command
85
-
89
+
86
90
  Returns:
87
91
  dict: Parsed JSON data or None if parsing failed
88
92
  """
89
93
  # The ADB content query output format is: "Row: 0 result={json_data}"
90
94
  # We need to extract the JSON part after "result="
91
- lines = raw_output.strip().split('\n')
92
-
95
+ lines = raw_output.strip().split("\n")
96
+
93
97
  for line in lines:
94
98
  line = line.strip()
95
-
99
+
96
100
  # Look for lines that contain "result=" pattern
97
101
  if "result=" in line:
98
102
  # Extract everything after "result="
99
103
  result_start = line.find("result=") + 7
100
104
  json_str = line[result_start:]
101
-
105
+
102
106
  try:
103
107
  # Parse the JSON string
104
108
  json_data = json.loads(json_str)
105
109
  return json_data
106
110
  except json.JSONDecodeError:
107
111
  continue
108
-
112
+
109
113
  # Fallback: try to parse lines that start with { or [
110
- elif line.startswith('{') or line.startswith('['):
114
+ elif line.startswith("{") or line.startswith("["):
111
115
  try:
112
116
  json_data = json.loads(line)
113
117
  return json_data
114
118
  except json.JSONDecodeError:
115
119
  continue
116
-
120
+
117
121
  # If no valid JSON found in individual lines, try the entire output
118
122
  try:
119
123
  json_data = json.loads(raw_output.strip())
@@ -199,7 +203,7 @@ class AdbTools(Tools):
199
203
  if not device:
200
204
  return f"Error: Device {serial} not found"
201
205
  else:
202
- device = await self.get_device()
206
+ device = await self._get_device()
203
207
 
204
208
  await device.tap(x, y)
205
209
 
@@ -246,7 +250,7 @@ class AdbTools(Tools):
246
250
  if not device:
247
251
  return f"Error: Device {self.serial} not found"
248
252
  else:
249
- device = await self.get_device()
253
+ device = await self._get_device()
250
254
 
251
255
  await device.tap(x, y)
252
256
  print(f"Tapped at coordinates ({x}, {y})")
@@ -292,11 +296,13 @@ class AdbTools(Tools):
292
296
  if not device:
293
297
  return f"Error: Device {self.serial} not found"
294
298
  else:
295
- device = await self.get_device()
299
+ device = await self._get_device()
296
300
 
297
301
  await device.swipe(start_x, start_y, end_x, end_y, duration_ms)
298
302
  await asyncio.sleep(1)
299
- print(f"Swiped from ({start_x}, {start_y}) to ({end_x}, {end_y}) in {duration_ms}ms")
303
+ print(
304
+ f"Swiped from ({start_x}, {start_y}) to ({end_x}, {end_y}) in {duration_ms}ms"
305
+ )
300
306
  return True
301
307
  except ValueError as e:
302
308
  print(f"Error: {str(e)}")
@@ -319,7 +325,7 @@ class AdbTools(Tools):
319
325
  if not device:
320
326
  return f"Error: Device {serial} not found"
321
327
  else:
322
- device = await self.get_device()
328
+ device = await self._get_device()
323
329
 
324
330
  # Save the current keyboard
325
331
  original_ime = await device._adb.shell(
@@ -372,7 +378,7 @@ class AdbTools(Tools):
372
378
  if not device:
373
379
  return f"Error: Device {self.serial} not found"
374
380
  else:
375
- device = await self.get_device()
381
+ device = await self._get_device()
376
382
 
377
383
  await device.press_key(3)
378
384
  return f"Pressed key BACK"
@@ -398,7 +404,7 @@ class AdbTools(Tools):
398
404
  if not device:
399
405
  return f"Error: Device {self.serial} not found"
400
406
  else:
401
- device = await self.get_device()
407
+ device = await self._get_device()
402
408
 
403
409
  key_names = {
404
410
  66: "ENTER",
@@ -427,7 +433,7 @@ class AdbTools(Tools):
427
433
  if not device:
428
434
  return f"Error: Device {self.serial} not found"
429
435
  else:
430
- device = await self.get_device()
436
+ device = await self._get_device()
431
437
 
432
438
  result = await device.start_app(package, activity)
433
439
  return result
@@ -451,7 +457,7 @@ class AdbTools(Tools):
451
457
  if not device:
452
458
  return f"Error: Device {self.serial} not found"
453
459
  else:
454
- device = await self.get_device()
460
+ device = await self._get_device()
455
461
 
456
462
  if not os.path.exists(apk_path):
457
463
  return f"Error: APK file not found at {apk_path}"
@@ -473,7 +479,7 @@ class AdbTools(Tools):
473
479
  if not device:
474
480
  raise ValueError(f"Device {self.serial} not found")
475
481
  else:
476
- device = await self.get_device()
482
+ device = await self._get_device()
477
483
  screen_tuple = await device.take_screenshot()
478
484
  self.last_screenshot = screen_tuple[1]
479
485
 
@@ -505,158 +511,12 @@ class AdbTools(Tools):
505
511
  if not device:
506
512
  raise ValueError(f"Device {self.serial} not found")
507
513
  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
514
+ device = await self._get_device()
515
+
516
+ return await device.list_packages(include_system_apps)
524
517
  except ValueError as e:
525
518
  raise ValueError(f"Error listing packages: {str(e)}")
526
519
 
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
520
  def complete(self, success: bool, reason: str = ""):
661
521
  """
662
522
  Mark the task as finished.
@@ -722,26 +582,26 @@ class AdbTools(Tools):
722
582
  Returns:
723
583
  Dictionary containing both 'a11y_tree' and 'phone_state' data
724
584
  """
725
-
585
+
726
586
  try:
727
587
  if serial:
728
588
  device = await self.device_manager.get_device(serial)
729
589
  if not device:
730
590
  raise ValueError(f"Device {serial} not found")
731
591
  else:
732
- device = await self.get_device()
592
+ device = await self._get_device()
733
593
 
734
594
  adb_output = await device._adb.shell(
735
595
  device._serial,
736
- 'content query --uri content://com.droidrun.portal/state'
596
+ "content query --uri content://com.droidrun.portal/state",
737
597
  )
738
598
 
739
599
  state_data = self._parse_content_provider_output(adb_output)
740
-
600
+
741
601
  if state_data is None:
742
602
  return {
743
603
  "error": "Parse Error",
744
- "message": "Failed to parse state data from ContentProvider response"
604
+ "message": "Failed to parse state data from ContentProvider response",
745
605
  }
746
606
 
747
607
  if isinstance(state_data, dict) and "data" in state_data:
@@ -751,25 +611,25 @@ class AdbTools(Tools):
751
611
  except json.JSONDecodeError:
752
612
  return {
753
613
  "error": "Parse Error",
754
- "message": "Failed to parse JSON data from ContentProvider data field"
614
+ "message": "Failed to parse JSON data from ContentProvider data field",
755
615
  }
756
616
  else:
757
617
  return {
758
618
  "error": "Format Error",
759
- "message": f"Unexpected state data format: {type(state_data)}"
619
+ "message": f"Unexpected state data format: {type(state_data)}",
760
620
  }
761
621
 
762
622
  # Validate that both a11y_tree and phone_state are present
763
623
  if "a11y_tree" not in combined_data:
764
624
  return {
765
625
  "error": "Missing Data",
766
- "message": "a11y_tree not found in combined state data"
626
+ "message": "a11y_tree not found in combined state data",
767
627
  }
768
-
628
+
769
629
  if "phone_state" not in combined_data:
770
630
  return {
771
- "error": "Missing Data",
772
- "message": "phone_state not found in combined state data"
631
+ "error": "Missing Data",
632
+ "message": "phone_state not found in combined state data",
773
633
  }
774
634
 
775
635
  # Filter out the "type" attribute from all a11y_tree elements
@@ -777,9 +637,7 @@ class AdbTools(Tools):
777
637
  filtered_elements = []
778
638
  for element in elements:
779
639
  # 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
- }
640
+ filtered_element = {k: v for k, v in element.items() if k != "type"}
783
641
 
784
642
  # Also filter children if present
785
643
  if "children" in filtered_element:
@@ -789,19 +647,25 @@ class AdbTools(Tools):
789
647
  ]
790
648
 
791
649
  filtered_elements.append(filtered_element)
792
-
650
+
793
651
  self.clickable_elements_cache = filtered_elements
794
-
652
+
795
653
  return {
796
654
  "a11y_tree": filtered_elements,
797
- "phone_state": combined_data["phone_state"]
655
+ "phone_state": combined_data["phone_state"],
798
656
  }
799
657
 
800
658
  except Exception as e:
801
- return {"error": str(e), "message": f"Error getting combined state: {str(e)}"}
659
+ return {
660
+ "error": str(e),
661
+ "message": f"Error getting combined state: {str(e)}",
662
+ }
663
+
802
664
 
803
665
  if __name__ == "__main__":
666
+
804
667
  async def main():
805
- tools = AdbTools()
668
+ tools = await AdbTools.create()
669
+ print(tools.serial)
806
670
 
807
- asyncio.run(main())
671
+ asyncio.run(main())
droidrun/tools/ios.py CHANGED
@@ -39,6 +39,12 @@ class IOSTools(Tools):
39
39
  """Core UI interaction tools for iOS device control."""
40
40
 
41
41
  def __init__(self, url: str, bundle_identifiers: List[str] = []) -> None:
42
+ """Initialize the IOSTools instance.
43
+
44
+ Args:
45
+ url: iOS device URL. This is the URL of the iOS device. It is used to send requests to the iOS device.
46
+ bundle_identifiers: List of bundle identifiers to include in the list of packages
47
+ """
42
48
  self.clickable_elements_cache: List[Dict[str, Any]] = []
43
49
  self.url = url
44
50
  self.last_screenshot = None
@@ -536,9 +542,6 @@ class IOSTools(Tools):
536
542
  all_packages.update(SYSTEM_BUNDLE_IDENTIFIERS)
537
543
  return sorted(list(all_packages))
538
544
 
539
- async def extract(self, filename: str | None = None) -> str:
540
- # TODO
541
- return "not implemented"
542
545
 
543
546
  async def remember(self, information: str) -> str:
544
547
  """
droidrun/tools/tools.py CHANGED
@@ -8,12 +8,23 @@ logger = logging.getLogger(__name__)
8
8
 
9
9
 
10
10
  class Tools(ABC):
11
+ """
12
+ Abstract base class for all tools.
13
+ This class provides a common interface for all tools to implement.
14
+ """
15
+
11
16
  @abstractmethod
12
17
  async def get_state(self) -> Dict[str, Any]:
18
+ """
19
+ Get the current state of the tool.
20
+ """
13
21
  pass
14
22
 
15
23
  @abstractmethod
16
24
  async def tap_by_index(self, index: int) -> bool:
25
+ """
26
+ Tap the element at the given index.
27
+ """
17
28
  pass
18
29
 
19
30
  #@abstractmethod
@@ -24,46 +35,72 @@ class Tools(ABC):
24
35
  async def swipe(
25
36
  self, start_x: int, start_y: int, end_x: int, end_y: int, duration_ms: int = 300
26
37
  ) -> bool:
38
+ """
39
+ Swipe from the given start coordinates to the given end coordinates.
40
+ """
27
41
  pass
28
42
 
29
43
  @abstractmethod
30
44
  async def input_text(self, text: str) -> bool:
45
+ """
46
+ Input the given text into a focused input field.
47
+ """
31
48
  pass
32
49
 
33
50
  @abstractmethod
34
51
  async def back(self) -> bool:
52
+ """
53
+ Press the back button.
54
+ """
35
55
  pass
36
56
 
37
57
  @abstractmethod
38
58
  async def press_key(self, keycode: int) -> bool:
59
+ """
60
+ Enter the given keycode.
61
+ """
39
62
  pass
40
63
 
41
64
  @abstractmethod
42
65
  async def start_app(self, package: str, activity: str = "") -> bool:
66
+ """
67
+ Start the given app.
68
+ """
43
69
  pass
44
70
 
45
71
  @abstractmethod
46
72
  async def take_screenshot(self) -> Tuple[str, bytes]:
73
+ """
74
+ Take a screenshot of the device.
75
+ """
47
76
  pass
48
77
 
49
78
  @abstractmethod
50
79
  async def list_packages(self, include_system_apps: bool = False) -> List[str]:
80
+ """
81
+ List all packages on the device.
82
+ """
51
83
  pass
52
84
 
53
85
  @abstractmethod
54
86
  async def remember(self, information: str) -> str:
87
+ """
88
+ Remember the given information. This is used to store information in the tool's memory.
89
+ """
55
90
  pass
56
91
 
57
92
  @abstractmethod
58
93
  async def get_memory(self) -> List[str]:
59
- pass
60
-
61
- @abstractmethod
62
- async def extract(self, filename: Optional[str] = None) -> str:
94
+ """
95
+ Get the memory of the tool.
96
+ """
63
97
  pass
64
98
 
65
99
  @abstractmethod
66
100
  def complete(self, success: bool, reason: str = "") -> bool:
101
+ """
102
+ Complete the tool. This is used to indicate that the tool has completed its task.
103
+ """
67
104
  pass
68
105
 
69
106
 
@@ -84,12 +121,10 @@ def describe_tools(tools: Tools) -> Dict[str, Callable[..., Any]]:
84
121
  "input_text": tools.input_text,
85
122
  "press_key": tools.press_key,
86
123
  "tap_by_index": tools.tap_by_index,
87
- # "tap_by_coordinates": tools_instance.tap_by_coordinates,
88
124
  # App management
89
125
  "start_app": tools.start_app,
90
126
  "list_packages": tools.list_packages,
91
127
  # state management
92
- "extract": tools.extract,
93
128
  "remember": tools.remember,
94
129
  "complete": tools.complete,
95
130
  }