droidrun 0.3.10.dev7__py3-none-any.whl → 0.3.10.dev9__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.
File without changes
File without changes
File without changes
droidrun/cli/main.py CHANGED
@@ -555,18 +555,15 @@ def run(
555
555
  )
556
556
  finally:
557
557
  # Disable DroidRun keyboard after execution
558
+ # Note: Port forwards are managed automatically and persist until device disconnect
558
559
  try:
559
560
  if not (ios if ios is not None else False):
560
- device_serial = adb.device().serial
561
- if device_serial:
562
- tools = AdbTools(serial=device, use_tcp=use_tcp if use_tcp is not None else False)
563
- if hasattr(tools, 'device') and tools.device:
564
- tools.device.shell("ime disable com.droidrun.portal/.DroidrunKeyboardIME")
565
- click.echo("DroidRun keyboard disabled successfully")
566
- # Cleanup tools
567
- del tools
568
- except Exception as disable_e:
569
- click.echo(f"Warning: Failed to disable DroidRun keyboard: {disable_e}")
561
+ device_obj = adb.device(device)
562
+ if device_obj:
563
+ device_obj.shell("ime disable com.droidrun.portal/.DroidrunKeyboardIME")
564
+ except Exception:
565
+ click.echo("Failed to disable DroidRun keyboard")
566
+ pass
570
567
 
571
568
 
572
569
  @cli.command()
droidrun/tools/adb.py CHANGED
@@ -2,15 +2,11 @@
2
2
  UI Actions - Core UI interaction tools for Android device control.
3
3
  """
4
4
 
5
- import base64
6
- import io
7
- import json
8
5
  import logging
9
6
  import os
10
7
  import time
11
8
  from typing import Any, Dict, List, Optional, Tuple
12
9
 
13
- import requests
14
10
  from adbutils import adb
15
11
  from llama_index.core.workflow import Context
16
12
 
@@ -24,6 +20,7 @@ from droidrun.agent.common.events import (
24
20
  )
25
21
  from droidrun.tools.tools import Tools
26
22
 
23
+ from droidrun.tools.portal_client import PortalClient
27
24
  logger = logging.getLogger("droidrun-tools")
28
25
  PORTAL_DEFAULT_TCP_PORT = 8080
29
26
 
@@ -49,9 +46,8 @@ class AdbTools(Tools):
49
46
  text_manipulator_llm: LLM instance for text manipulation (optional)
50
47
  """
51
48
  self.device = adb.device(serial=serial)
52
- self.use_tcp = use_tcp
53
- self.remote_tcp_port = remote_tcp_port
54
- self.tcp_forwarded = False
49
+
50
+ self.portal = PortalClient(self.device, prefer_tcp=use_tcp)
55
51
 
56
52
  self._ctx = None
57
53
  # Instance‐level cache for clickable elements (index-based tapping)
@@ -75,10 +71,6 @@ class AdbTools(Tools):
75
71
  from droidrun.portal import setup_keyboard
76
72
  setup_keyboard(self.device)
77
73
 
78
- # Set up TCP forwarding if requested
79
- if self.use_tcp:
80
- self.setup_tcp_forward()
81
-
82
74
 
83
75
  def get_date(self) -> str:
84
76
  """
@@ -86,127 +78,9 @@ class AdbTools(Tools):
86
78
  """
87
79
  return self.device.shell("date").strip()
88
80
 
89
-
90
- def setup_tcp_forward(self) -> bool:
91
- """
92
- Set up ADB TCP port forwarding for communication with the portal app.
93
-
94
- Returns:
95
- bool: True if forwarding was set up successfully, False otherwise
96
- """
97
- try:
98
- logger.debug(
99
- f"Setting up TCP port forwarding for port tcp:{self.remote_tcp_port} on device {self.device.serial}"
100
- )
101
- # Use adb forward command to set up port forwarding
102
- self.local_tcp_port = self.device.forward_port(self.remote_tcp_port)
103
- self.tcp_base_url = f"http://localhost:{self.local_tcp_port}"
104
- logger.debug(
105
- f"TCP port forwarding set up successfully to {self.tcp_base_url}"
106
- )
107
-
108
- # Test the connection with a ping
109
- try:
110
- response = requests.get(f"{self.tcp_base_url}/ping", timeout=5)
111
- if response.status_code == 200:
112
- logger.debug("TCP connection test successful")
113
- self.tcp_forwarded = True
114
- return True
115
- else:
116
- logger.warning(
117
- f"TCP connection test failed with status: {response.status_code}"
118
- )
119
- return False
120
- except requests.exceptions.RequestException as e:
121
- logger.warning(f"TCP connection test failed: {e}")
122
- return False
123
-
124
- except Exception as e:
125
- logger.error(f"Failed to set up TCP port forwarding: {e}")
126
- self.tcp_forwarded = False
127
- return False
128
-
129
- def teardown_tcp_forward(self) -> bool:
130
- """
131
- Remove ADB TCP port forwarding.
132
-
133
- Returns:
134
- bool: True if forwarding was removed successfully, False otherwise
135
- """
136
- try:
137
- if self.tcp_forwarded:
138
- logger.debug(
139
- f"Removing TCP port forwarding for port {self.local_tcp_port}"
140
- )
141
- # remove forwarding
142
- cmd = f"killforward:tcp:{self.local_tcp_port}"
143
- logger.debug(f"Removing TCP port forwarding: {cmd}")
144
- c = self.device.open_transport(cmd)
145
- c.close()
146
-
147
- self.tcp_forwarded = False
148
- logger.debug("TCP port forwarding removed")
149
- return True
150
- return True
151
- except Exception as e:
152
- logger.error(f"Failed to remove TCP port forwarding: {e}")
153
- return False
154
-
155
- def __del__(self):
156
- """Cleanup when the object is destroyed."""
157
- if hasattr(self, "tcp_forwarded") and self.tcp_forwarded:
158
- self.teardown_tcp_forward()
159
-
160
81
  def _set_context(self, ctx: Context):
161
82
  self._ctx = ctx
162
83
 
163
- def _parse_content_provider_output(
164
- self, raw_output: str
165
- ) -> Optional[Dict[str, Any]]:
166
- """
167
- Parse the raw ADB content provider output and extract JSON data.
168
-
169
- Args:
170
- raw_output (str): Raw output from ADB content query command
171
-
172
- Returns:
173
- dict: Parsed JSON data or None if parsing failed
174
- """
175
- # The ADB content query output format is: "Row: 0 result={json_data}"
176
- # We need to extract the JSON part after "result="
177
- lines = raw_output.strip().split("\n")
178
-
179
- for line in lines:
180
- line = line.strip()
181
-
182
- # Look for lines that contain "result=" pattern
183
- if "result=" in line:
184
- # Extract everything after "result="
185
- result_start = line.find("result=") + 7
186
- json_str = line[result_start:]
187
-
188
- try:
189
- # Parse the JSON string
190
- json_data = json.loads(json_str)
191
- return json_data
192
- except json.JSONDecodeError:
193
- continue
194
-
195
- # Fallback: try to parse lines that start with { or [
196
- elif line.startswith("{") or line.startswith("["):
197
- try:
198
- json_data = json.loads(line)
199
- return json_data
200
- except json.JSONDecodeError:
201
- continue
202
-
203
- # If no valid JSON found in individual lines, try the entire output
204
- try:
205
- json_data = json.loads(raw_output.strip())
206
- return json_data
207
- except json.JSONDecodeError:
208
- return None
209
-
210
84
  @Tools.ui_action
211
85
  def _extract_element_coordinates_by_index(self, index: int) -> Tuple[int, int]:
212
86
  """
@@ -510,52 +384,9 @@ class AdbTools(Tools):
510
384
  try:
511
385
  if index != -1:
512
386
  self.tap_by_index(index)
513
- # Encode the text to Base64 (needed for both TCP and content provider)
514
- encoded_text = base64.b64encode(text.encode()).decode()
515
-
516
- if self.use_tcp and self.tcp_forwarded:
517
- # Use TCP communication
518
- payload = {
519
- "base64_text": encoded_text,
520
- "clear": clear # Include clear parameter for TCP
521
- }
522
- response = requests.post(
523
- f"{self.tcp_base_url}/keyboard/input",
524
- json=payload,
525
- headers={"Content-Type": "application/json"},
526
- timeout=10,
527
- )
528
-
529
- print(
530
- f"Keyboard input TCP response: {response.status_code}, {response.text}"
531
- )
532
387
 
533
- if response.status_code != 200:
534
- return f"Error: HTTP request failed with status {response.status_code}: {response.text}"
535
-
536
- # For TCP, you might want to parse the response for success/error details
537
- try:
538
- result_data = response.json()
539
- if result_data.get("status") == "success":
540
- return f"Text input completed (clear={clear}): {text[:50]}{'...' if len(text) > 50 else ''}"
541
- else:
542
- return f"Error: {result_data.get('error', 'Unknown error')}"
543
- except: # noqa: E722
544
- return f"Text input completed (clear={clear}): {text[:50]}{'...' if len(text) > 50 else ''}"
545
-
546
- else:
547
- # Fallback to content provider method
548
- # Build the content insert command with clear parameter
549
- clear_str = "true" if clear else "false"
550
- cmd = (
551
- f'content insert --uri "content://com.droidrun.portal/keyboard/input" '
552
- f'--bind base64_text:s:"{encoded_text}" '
553
- f'--bind clear:b:{clear_str}'
554
- )
555
-
556
- # Execute the command and capture output for better error handling
557
- result = self.device.shell(cmd)
558
- print(f"Content provider result: {result}")
388
+ # Use PortalClient for text input (automatic TCP/content provider selection)
389
+ success = self.portal.input_text(text, clear)
559
390
 
560
391
  if self._ctx:
561
392
  input_event = InputTextActionEvent(
@@ -565,15 +396,12 @@ class AdbTools(Tools):
565
396
  )
566
397
  self._ctx.write_event_to_stream(input_event)
567
398
 
568
- print(
569
- f"Text input completed (clear={clear}): {text[:50]}{'...' if len(text) > 50 else ''}"
570
- )
571
- return f"Text input completed (clear={clear}): {text[:50]}{'...' if len(text) > 50 else ''}"
399
+ if success:
400
+ print(f"Text input completed (clear={clear}): {text[:50]}{'...' if len(text) > 50 else ''}")
401
+ return f"Text input completed (clear={clear}): {text[:50]}{'...' if len(text) > 50 else ''}"
402
+ else:
403
+ return "Error: Text input failed"
572
404
 
573
- except requests.exceptions.RequestException as e:
574
- return f"Error: TCP request failed: {str(e)}"
575
- except ValueError as e:
576
- return f"Error: {str(e)}"
577
405
  except Exception as e:
578
406
  return f"Error sending text input: {str(e)}"
579
407
 
@@ -714,56 +542,21 @@ class AdbTools(Tools):
714
542
  """
715
543
  try:
716
544
  logger.debug("Taking screenshot")
717
- img_format = "PNG"
718
- image_bytes = None
719
-
720
- if self.use_tcp and self.tcp_forwarded:
721
- # Add hideOverlay parameter to URL
722
- url = f"{self.tcp_base_url}/screenshot"
723
- if not hide_overlay:
724
- url += "?hideOverlay=false"
725
-
726
- response = requests.get(url, timeout=10)
727
- if response.status_code == 200:
728
- tcp_response = response.json()
729
-
730
- # Check if response has the expected format with data field
731
- if tcp_response.get("status") == "success" and "data" in tcp_response:
732
- # Decode base64 string to bytes
733
- base64_data = tcp_response["data"]
734
- image_bytes = base64.b64decode(base64_data)
735
- logger.debug("Screenshot taken via TCP")
736
- else:
737
- # Handle error response from server
738
- error_msg = tcp_response.get("error", "Unknown error")
739
- raise ValueError(f"Error taking screenshot via TCP: {error_msg}")
740
- else:
741
- raise ValueError(f"Error taking screenshot via TCP: {response.status_code}")
742
545
 
743
- else:
744
- # Fallback to ADB screenshot method
745
- img = self.device.screenshot()
746
- img_buf = io.BytesIO()
747
- img.save(img_buf, format=img_format)
748
- image_bytes = img_buf.getvalue()
749
- logger.debug("Screenshot taken via ADB")
546
+ image_bytes = self.portal.take_screenshot(hide_overlay)
750
547
 
751
548
  # Store screenshot with timestamp
752
549
  self.screenshots.append(
753
550
  {
754
551
  "timestamp": time.time(),
755
552
  "image_data": image_bytes,
756
- "format": img_format,
553
+ "format": "PNG",
757
554
  }
758
555
  )
759
- return img_format, image_bytes
556
+ return "PNG", image_bytes
760
557
 
761
- except requests.exceptions.RequestException as e:
762
- raise ValueError(f"Error taking screenshot via TCP: {str(e)}") from e
763
- except ValueError as e:
764
- raise ValueError(f"Error taking screenshot: {str(e)}") from e
765
558
  except Exception as e:
766
- raise ValueError(f"Unexpected error taking screenshot: {str(e)}") from e
559
+ raise ValueError(f"Error taking screenshot: {str(e)}") from e
767
560
 
768
561
 
769
562
  def list_packages(self, include_system_apps: bool = False) -> List[str]:
@@ -792,38 +585,7 @@ class AdbTools(Tools):
792
585
  Returns:
793
586
  List of dictionaries containing 'package' and 'label' keys
794
587
  """
795
- try:
796
- logger.debug("Getting apps via content provider")
797
-
798
- # Query the content provider for packages
799
- adb_output = self.device.shell(
800
- "content query --uri content://com.droidrun.portal/packages"
801
- )
802
-
803
- # Parse the content provider output
804
- packages_data = self._parse_content_provider_output(adb_output)
805
-
806
- if not packages_data or "packages" not in packages_data:
807
- logger.warning("No packages data found in content provider response")
808
- return []
809
-
810
- apps = []
811
- for package_info in packages_data["packages"]:
812
- # Filter system apps if requested
813
- if not include_system and package_info.get("isSystemApp", False):
814
- continue
815
-
816
- apps.append({
817
- "package": package_info.get("packageName", ""),
818
- "label": package_info.get("label", "")
819
- })
820
-
821
- logger.debug(f"Found {len(apps)} apps")
822
- return apps
823
-
824
- except Exception as e:
825
- logger.error(f"Error getting apps: {str(e)}")
826
- raise ValueError(f"Error getting apps: {str(e)}") from e
588
+ return self.portal.get_apps(include_system)
827
589
 
828
590
  @Tools.ui_action
829
591
  def complete(self, success: bool, reason: str = ""):
@@ -881,82 +643,22 @@ class AdbTools(Tools):
881
643
  """
882
644
  return self.memory.copy()
883
645
 
884
- def get_state(self, serial: Optional[str] = None) -> Dict[str, Any]:
646
+ def get_state(self) -> Dict[str, Any]:
885
647
  """
886
648
  Get both the a11y tree and phone state in a single call using the combined /state endpoint.
887
649
 
888
- Args:
889
- serial: Optional device serial number
890
-
891
650
  Returns:
892
651
  Dictionary containing both 'a11y_tree' and 'phone_state' data
893
652
  """
894
-
895
653
  try:
896
654
  logger.debug("Getting state")
897
655
 
898
- if self.use_tcp and self.tcp_forwarded:
899
- # Use TCP communication
900
- response = requests.get(f"{self.tcp_base_url}/state", timeout=10)
901
-
902
- if response.status_code == 200:
903
- tcp_response = response.json()
904
-
905
- # Check if response has the expected format
906
- if isinstance(tcp_response, dict) and "data" in tcp_response:
907
- data_str = tcp_response["data"]
908
- try:
909
- combined_data = json.loads(data_str)
910
- except json.JSONDecodeError:
911
- return {
912
- "error": "Parse Error",
913
- "message": "Failed to parse JSON data from TCP response data field",
914
- }
915
- else:
916
- # Fallback: assume direct JSON format
917
- combined_data = tcp_response
918
- else:
919
- return {
920
- "error": "HTTP Error",
921
- "message": f"HTTP request failed with status {response.status_code}",
922
- }
923
- else:
924
- # Fallback to content provider method
925
- adb_output = self.device.shell(
926
- "content query --uri content://com.droidrun.portal/state",
927
- )
656
+ # Use PortalClient for state (automatic TCP/content provider selection)
657
+ combined_data = self.portal.get_state()
928
658
 
929
- state_data = self._parse_content_provider_output(adb_output)
930
-
931
- if state_data is None:
932
- return {
933
- "error": "Parse Error",
934
- "message": "Failed to parse state data from ContentProvider response",
935
- }
936
-
937
- if isinstance(state_data, dict):
938
- data_str = None
939
- if "data" in state_data:
940
- data_str = state_data["data"]
941
-
942
- if data_str:
943
- try:
944
- combined_data = json.loads(data_str)
945
- except json.JSONDecodeError:
946
- return {
947
- "error": "Parse Error",
948
- "message": "Failed to parse JSON data from ContentProvider response",
949
- }
950
- else:
951
- return {
952
- "error": "Format Error",
953
- "message": "Neither 'data' nor 'message' field found in ContentProvider response",
954
- }
955
- else:
956
- return {
957
- "error": "Format Error",
958
- "message": f"Unexpected state data format: {type(state_data)}",
959
- }
659
+ # Handle error responses
660
+ if "error" in combined_data:
661
+ return combined_data
960
662
 
961
663
  # Validate that both a11y_tree and phone_state are present
962
664
  if "a11y_tree" not in combined_data:
@@ -994,11 +696,6 @@ class AdbTools(Tools):
994
696
  "phone_state": combined_data["phone_state"],
995
697
  }
996
698
 
997
- except requests.exceptions.RequestException as e:
998
- return {
999
- "error": "TCP Error",
1000
- "message": f"TCP request failed: {str(e)}",
1001
- }
1002
699
  except Exception as e:
1003
700
  return {
1004
701
  "error": str(e),
@@ -1007,51 +704,12 @@ class AdbTools(Tools):
1007
704
 
1008
705
  def ping(self) -> Dict[str, Any]:
1009
706
  """
1010
- Test the TCP connection using the /ping endpoint.
707
+ Test the Portal connection.
1011
708
 
1012
709
  Returns:
1013
710
  Dictionary with ping result
1014
711
  """
1015
- try:
1016
- if self.use_tcp and self.tcp_forwarded:
1017
- response = requests.get(f"{self.tcp_base_url}/ping", timeout=5)
1018
-
1019
- if response.status_code == 200:
1020
- try:
1021
- tcp_response = response.json() if response.content else {}
1022
- logger.debug(f"Ping TCP response: {tcp_response}")
1023
- return {
1024
- "status": "success",
1025
- "message": "Ping successful",
1026
- "response": tcp_response,
1027
- }
1028
- except json.JSONDecodeError:
1029
- return {
1030
- "status": "success",
1031
- "message": "Ping successful (non-JSON response)",
1032
- "response": response.text,
1033
- }
1034
- else:
1035
- return {
1036
- "status": "error",
1037
- "message": f"Ping failed with status {response.status_code}: {response.text}",
1038
- }
1039
- else:
1040
- return {
1041
- "status": "error",
1042
- "message": "TCP communication is not enabled",
1043
- }
1044
-
1045
- except requests.exceptions.RequestException as e:
1046
- return {
1047
- "status": "error",
1048
- "message": f"Ping failed: {str(e)}",
1049
- }
1050
- except Exception as e:
1051
- return {
1052
- "status": "error",
1053
- "message": f"Error during ping: {str(e)}",
1054
- }
712
+ return self.portal.ping()
1055
713
 
1056
714
 
1057
715
  def _shell_test_cli(serial: str, command: str) -> tuple[str, float]:
@@ -0,0 +1,434 @@
1
+ """
2
+ Portal Client - Unified communication layer for DroidRun Portal app.
3
+
4
+ This module provides automatic TCP/Content Provider fallback for Portal communication.
5
+ """
6
+
7
+ import base64
8
+ import io
9
+ import json
10
+ import logging
11
+ import re
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ import requests
15
+ from adbutils import AdbDevice
16
+
17
+ logger = logging.getLogger("droidrun-tools")
18
+
19
+ PORTAL_REMOTE_PORT = 8080 # Port on device where Portal HTTP server runs
20
+
21
+
22
+ class PortalClient:
23
+ """
24
+ Unified client for DroidRun Portal communication.
25
+
26
+ Automatically handles TCP vs Content Provider fallback with the following strategy:
27
+ - On init, checks for existing port forward and reuses it
28
+ - If no forward exists, creates new one
29
+ - Tests connection and sets tcp_available flag
30
+ - All methods auto-select TCP or content provider based on availability
31
+ - No cleanup needed - forwards persist until device disconnect
32
+
33
+ Key features:
34
+ - Reuses existing port forwards (no cleanup needed)
35
+ - Automatic fallback to content provider if TCP fails
36
+ - Zero explicit resource management
37
+ - Graceful degradation
38
+ """
39
+
40
+ def __init__(self, device: AdbDevice, prefer_tcp: bool = False):
41
+ """
42
+ Initialize Portal client.
43
+
44
+ Args:
45
+ device: ADB device instance
46
+ prefer_tcp: Whether to prefer TCP communication (will fallback to content provider if unavailable)
47
+ """
48
+ self.device = device
49
+ self.tcp_available = False
50
+ self.tcp_base_url = None
51
+ self.local_tcp_port = None
52
+
53
+ if prefer_tcp:
54
+ self._try_enable_tcp()
55
+
56
+ def _try_enable_tcp(self) -> None:
57
+ """
58
+ Try to enable TCP communication. Fails silently and falls back to content provider.
59
+
60
+ Strategy:
61
+ 1. Check if forward already exists → reuse
62
+ 2. If not, create new forward
63
+ 3. Test connection with ping
64
+ 4. Set tcp_available flag
65
+ """
66
+ try:
67
+ # Step 1: Check for existing forward
68
+ local_port = self._find_existing_forward()
69
+
70
+ # Step 2: If no forward exists, create one
71
+ if local_port is None:
72
+ logger.debug(f"No existing forward found, creating new forward for port {PORTAL_REMOTE_PORT}")
73
+ local_port = self.device.forward_port(PORTAL_REMOTE_PORT)
74
+ logger.debug(f"Created forward: localhost:{local_port} -> device:{PORTAL_REMOTE_PORT}")
75
+ else:
76
+ logger.debug(f"Reusing existing forward: localhost:{local_port} -> device:{PORTAL_REMOTE_PORT}")
77
+
78
+ # Store local port
79
+ self.local_tcp_port = local_port
80
+
81
+ # Step 3: Test connection
82
+ self.tcp_base_url = f"http://localhost:{local_port}"
83
+ if self._test_connection():
84
+ self.tcp_available = True
85
+ logger.info(f"✓ TCP mode enabled: {self.tcp_base_url}")
86
+ else:
87
+ logger.warning("TCP connection test failed, falling back to content provider")
88
+ self.tcp_available = False
89
+
90
+ except Exception as e:
91
+ logger.warning(f"Failed to setup TCP forwarding: {e}. Using content provider fallback.")
92
+ self.tcp_available = False
93
+
94
+ def _find_existing_forward(self) -> Optional[int]:
95
+ """
96
+ Check if a forward already exists for the Portal remote port.
97
+
98
+ Returns:
99
+ Local port number if forward exists, None otherwise
100
+ """
101
+ try:
102
+ forwards = self.device.forward_list()
103
+ # Format: ['serial tcp:local_port tcp:remote_port', ...]
104
+ for forward in forwards:
105
+ if self.device.serial in forward and f"tcp:{PORTAL_REMOTE_PORT}" in forward:
106
+ # Extract local port: "serial tcp:12345 tcp:8080"
107
+ match = re.search(r'tcp:(\d+)\s+tcp:' + str(PORTAL_REMOTE_PORT), forward)
108
+ if match:
109
+ local_port = int(match.group(1))
110
+ logger.debug(f"Found existing forward: localhost:{local_port} -> {PORTAL_REMOTE_PORT}")
111
+ return local_port
112
+ except Exception as e:
113
+ logger.debug(f"Failed to check existing forwards: {e}")
114
+
115
+ return None
116
+
117
+ def _test_connection(self) -> bool:
118
+ """Test if TCP connection to Portal is working."""
119
+ try:
120
+ response = requests.get(f"{self.tcp_base_url}/ping", timeout=3)
121
+ return response.status_code == 200
122
+ except Exception as e:
123
+ logger.debug(f"TCP connection test failed: {e}")
124
+ return False
125
+
126
+ def _parse_content_provider_output(self, raw_output: str) -> Optional[Dict[str, Any]]:
127
+ """
128
+ Parse the raw ADB content provider output and extract JSON data.
129
+
130
+ Args:
131
+ raw_output: Raw output from ADB content query command
132
+
133
+ Returns:
134
+ Parsed JSON data or None if parsing failed
135
+ """
136
+ lines = raw_output.strip().split("\n")
137
+
138
+ # Try line-by-line parsing
139
+ for line in lines:
140
+ line = line.strip()
141
+
142
+ # Look for "result=" pattern (common content provider format)
143
+ if "result=" in line:
144
+ result_start = line.find("result=") + 7
145
+ json_str = line[result_start:]
146
+ try:
147
+ json_data = json.loads(json_str)
148
+ # Handle nested "data" field with JSON string
149
+ if isinstance(json_data, dict) and "data" in json_data:
150
+ if isinstance(json_data["data"], str):
151
+ return json.loads(json_data["data"])
152
+ return json_data
153
+ except json.JSONDecodeError:
154
+ continue
155
+
156
+ # Fallback: try lines starting with JSON
157
+ elif line.startswith("{") or line.startswith("["):
158
+ try:
159
+ return json.loads(line)
160
+ except json.JSONDecodeError:
161
+ continue
162
+
163
+ # Last resort: try parsing entire output
164
+ try:
165
+ return json.loads(raw_output.strip())
166
+ except json.JSONDecodeError:
167
+ return None
168
+
169
+
170
+ def get_state(self) -> Dict[str, Any]:
171
+ """
172
+ Get device state (accessibility tree + phone state).
173
+ Auto-selects TCP or content provider.
174
+
175
+ Returns:
176
+ Dictionary containing 'a11y_tree' and 'phone_state' keys
177
+ """
178
+ if self.tcp_available:
179
+ return self._get_state_tcp()
180
+ return self._get_state_content_provider()
181
+
182
+ def _get_state_tcp(self) -> Dict[str, Any]:
183
+ """Get state via TCP."""
184
+ try:
185
+ response = requests.get(f"{self.tcp_base_url}/state", timeout=10)
186
+ if response.status_code == 200:
187
+ data = response.json()
188
+
189
+ # Handle nested "data" field
190
+ if isinstance(data, dict) and "data" in data:
191
+ if isinstance(data["data"], str):
192
+ return json.loads(data["data"])
193
+ return data
194
+ else:
195
+ logger.warning(f"TCP get_state failed ({response.status_code}), falling back")
196
+ return self._get_state_content_provider()
197
+ except Exception as e:
198
+ logger.warning(f"TCP get_state error: {e}, falling back")
199
+ return self._get_state_content_provider()
200
+
201
+ def _get_state_content_provider(self) -> Dict[str, Any]:
202
+ """Get state via content provider (fallback)."""
203
+ try:
204
+ output = self.device.shell("content query --uri content://com.droidrun.portal/state")
205
+ state_data = self._parse_content_provider_output(output)
206
+
207
+ if state_data is None:
208
+ return {
209
+ "error": "Parse Error",
210
+ "message": "Failed to parse state data from ContentProvider"
211
+ }
212
+
213
+ # Handle nested "data" field if present
214
+ if isinstance(state_data, dict) and "data" in state_data:
215
+ if isinstance(state_data["data"], str):
216
+ try:
217
+ return json.loads(state_data["data"])
218
+ except json.JSONDecodeError:
219
+ return {
220
+ "error": "Parse Error",
221
+ "message": "Failed to parse nested JSON data"
222
+ }
223
+
224
+ return state_data
225
+
226
+ except Exception as e:
227
+ return {
228
+ "error": "ContentProvider Error",
229
+ "message": str(e)
230
+ }
231
+
232
+
233
+ def input_text(self, text: str, clear: bool = False) -> bool:
234
+ """
235
+ Input text via keyboard.
236
+ Auto-selects TCP or content provider.
237
+
238
+ Args:
239
+ text: Text to input
240
+ clear: Whether to clear existing text first
241
+
242
+ Returns:
243
+ True if successful, False otherwise
244
+ """
245
+ if self.tcp_available:
246
+ return self._input_text_tcp(text, clear)
247
+ return self._input_text_content_provider(text, clear)
248
+
249
+ def _input_text_tcp(self, text: str, clear: bool) -> bool:
250
+ """Input text via TCP."""
251
+ try:
252
+ encoded = base64.b64encode(text.encode()).decode()
253
+ payload = {"base64_text": encoded, "clear": clear}
254
+ response = requests.post(
255
+ f"{self.tcp_base_url}/keyboard/input",
256
+ json=payload,
257
+ headers={"Content-Type": "application/json"},
258
+ timeout=10
259
+ )
260
+ if response.status_code == 200:
261
+ logger.debug(f"TCP input_text successful")
262
+ return True
263
+ else:
264
+ logger.warning(f"TCP input_text failed ({response.status_code}), falling back")
265
+ return self._input_text_content_provider(text, clear)
266
+ except Exception as e:
267
+ logger.warning(f"TCP input_text error: {e}, falling back")
268
+ return self._input_text_content_provider(text, clear)
269
+
270
+ def _input_text_content_provider(self, text: str, clear: bool) -> bool:
271
+ """Input text via content provider (fallback)."""
272
+ try:
273
+ encoded = base64.b64encode(text.encode()).decode()
274
+ clear_str = "true" if clear else "false"
275
+ cmd = (
276
+ f'content insert --uri "content://com.droidrun.portal/keyboard/input" '
277
+ f'--bind base64_text:s:"{encoded}" '
278
+ f'--bind clear:b:{clear_str}'
279
+ )
280
+ self.device.shell(cmd)
281
+ logger.debug("Content provider input_text successful")
282
+ return True
283
+ except Exception as e:
284
+ logger.error(f"Content provider input_text error: {e}")
285
+ return False
286
+
287
+
288
+ def take_screenshot(self, hide_overlay: bool = True) -> bytes:
289
+ """
290
+ Take screenshot of device.
291
+ Auto-selects TCP or ADB screencap.
292
+
293
+ Args:
294
+ hide_overlay: Whether to hide Portal overlay during screenshot
295
+
296
+ Returns:
297
+ Screenshot image bytes (PNG format)
298
+ """
299
+ if self.tcp_available:
300
+ return self._take_screenshot_tcp(hide_overlay)
301
+ return self._take_screenshot_adb()
302
+
303
+ def _take_screenshot_tcp(self, hide_overlay: bool) -> bytes:
304
+ """Take screenshot via TCP."""
305
+ try:
306
+ url = f"{self.tcp_base_url}/screenshot"
307
+ if not hide_overlay:
308
+ url += "?hideOverlay=false"
309
+
310
+ response = requests.get(url, timeout=10)
311
+ if response.status_code == 200:
312
+ data = response.json()
313
+ if data.get("status") == "success" and "data" in data:
314
+ logger.debug("Screenshot taken via TCP")
315
+ return base64.b64decode(data["data"])
316
+ else:
317
+ logger.warning("TCP screenshot failed (invalid response), falling back")
318
+ return self._take_screenshot_adb()
319
+ else:
320
+ logger.warning(f"TCP screenshot failed ({response.status_code}), falling back")
321
+ return self._take_screenshot_adb()
322
+ except Exception as e:
323
+ logger.warning(f"TCP screenshot error: {e}, falling back")
324
+ return self._take_screenshot_adb()
325
+
326
+ def _take_screenshot_adb(self) -> bytes:
327
+ """Take screenshot via ADB screencap (fallback)."""
328
+ img = self.device.screenshot()
329
+ buf = io.BytesIO()
330
+ img.save(buf, format="PNG")
331
+ logger.debug("Screenshot taken via ADB")
332
+ return buf.getvalue()
333
+
334
+ def get_apps(self, include_system: bool = True) -> List[Dict[str, str]]:
335
+ """
336
+ Get installed apps with package name and label.
337
+
338
+ Note: Currently only supports content provider (no TCP endpoint exists yet)
339
+
340
+ Args:
341
+ include_system: Whether to include system apps
342
+
343
+ Returns:
344
+ List of dicts with 'package' and 'label' keys
345
+ """
346
+ try:
347
+ logger.debug("Getting apps via content provider")
348
+
349
+ # Query content provider
350
+ output = self.device.shell("content query --uri content://com.droidrun.portal/packages")
351
+ packages_data = self._parse_content_provider_output(output)
352
+
353
+ if not packages_data or "packages" not in packages_data:
354
+ logger.warning("No packages data found in content provider response")
355
+ return []
356
+
357
+ # Filter and format apps
358
+ apps = []
359
+ for package_info in packages_data["packages"]:
360
+ if not include_system and package_info.get("isSystemApp", False):
361
+ continue
362
+
363
+ apps.append({
364
+ "package": package_info.get("packageName", ""),
365
+ "label": package_info.get("label", "")
366
+ })
367
+
368
+ logger.debug(f"Found {len(apps)} apps")
369
+ return apps
370
+
371
+ except Exception as e:
372
+ logger.error(f"Error getting apps: {e}")
373
+ raise ValueError(f"Error getting apps: {e}") from e
374
+
375
+
376
+ def ping(self) -> Dict[str, Any]:
377
+ """
378
+ Test Portal connection.
379
+
380
+ Returns:
381
+ Dictionary with status and connection details
382
+ """
383
+ if self.tcp_available:
384
+ try:
385
+ response = requests.get(f"{self.tcp_base_url}/ping", timeout=5)
386
+ if response.status_code == 200:
387
+ try:
388
+ tcp_response = response.json() if response.content else {}
389
+ return {
390
+ "status": "success",
391
+ "method": "tcp",
392
+ "url": self.tcp_base_url,
393
+ "response": tcp_response
394
+ }
395
+ except json.JSONDecodeError:
396
+ return {
397
+ "status": "success",
398
+ "method": "tcp",
399
+ "url": self.tcp_base_url,
400
+ "response": response.text
401
+ }
402
+ else:
403
+ return {
404
+ "status": "error",
405
+ "method": "tcp",
406
+ "message": f"HTTP {response.status_code}: {response.text}"
407
+ }
408
+ except Exception as e:
409
+ return {
410
+ "status": "error",
411
+ "method": "tcp",
412
+ "message": str(e)
413
+ }
414
+ else:
415
+ # Test content provider
416
+ try:
417
+ output = self.device.shell("content query --uri content://com.droidrun.portal/state")
418
+ if "Row: 0 result=" in output:
419
+ return {
420
+ "status": "success",
421
+ "method": "content_provider"
422
+ }
423
+ else:
424
+ return {
425
+ "status": "error",
426
+ "method": "content_provider",
427
+ "message": "Invalid response"
428
+ }
429
+ except Exception as e:
430
+ return {
431
+ "status": "error",
432
+ "method": "content_provider",
433
+ "message": str(e)
434
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: droidrun
3
- Version: 0.3.10.dev7
3
+ Version: 0.3.10.dev9
4
4
  Summary: A framework for controlling Android devices through LLM agents
5
5
  Project-URL: Homepage, https://github.com/droidrun/droidrun
6
6
  Project-URL: Bug Tracker, https://github.com/droidrun/droidrun/issues
@@ -6,6 +6,7 @@ droidrun/agent/usage.py,sha256=6PVeHctNa0EmHmNPTdOUv5e3-EK6AMu6D2Pz5OMqs5c,7145
6
6
  droidrun/agent/codeact/__init__.py,sha256=lagBdrury33kbHN1XEZ-xzJ-RywmpkUUoUidOno9ym8,96
7
7
  droidrun/agent/codeact/codeact_agent.py,sha256=7EkuazNIpTOX-W1oSG0XmtOwcmNOyaiPddgNxnK10No,20292
8
8
  droidrun/agent/codeact/events.py,sha256=kRKTQPzogPiQwmOCc_fGcg1g1zDXXVeBpDl45GTdpYU,734
9
+ droidrun/agent/common/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
10
  droidrun/agent/common/constants.py,sha256=q7ywmOXCsJZg8m9ctpzQ-nxvuj5GMn28Pr8z3dMj1Rg,94
10
11
  droidrun/agent/common/events.py,sha256=rbPWdlqNNMdnVjYhJOL2mJcNNORHhjXOkY8XiLPzp7c,1182
11
12
  droidrun/agent/context/__init__.py,sha256=-CiAv66qym_WgFy5vCRfNLxmiprmEbssu6S_2jj0LZw,452
@@ -22,6 +23,7 @@ droidrun/agent/manager/__init__.py,sha256=A8esHVpxzHd3Epzkl0j5seNkRQqwNEn1a97eeL
22
23
  droidrun/agent/manager/events.py,sha256=X0tUwCX2mU8I4bGR4JW2NmUqiOrX-Hrb017vGVPVyHw,855
23
24
  droidrun/agent/manager/manager_agent.py,sha256=nXftmLlSLDs9LLB3rHE3EzpaCnUKa6v1dNfjFTMV9ys,22256
24
25
  droidrun/agent/manager/prompts.py,sha256=qfDYcSbpWpnUaavAuPE6qY6Df6w25LmtY1mEiBUMti0,2060
26
+ droidrun/agent/oneflows/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
27
  droidrun/agent/oneflows/app_starter_workflow.py,sha256=MSJ6_jfbiCfSIjnw-qfSDFDuqsUS6rUGLsdKVj43wvY,3525
26
28
  droidrun/agent/oneflows/text_manipulator.py,sha256=mO59DF1uif9poUWy90UehrBmHbNxL9ph4Evtgt1ODbQ,8751
27
29
  droidrun/agent/utils/__init__.py,sha256=Oro0oyiz1xzRpchWLDA1TZJELJNSwBOb2WdGgknthKo,244
@@ -34,6 +36,7 @@ droidrun/agent/utils/llm_picker.py,sha256=KQzrRcHE38NwujDbNth5F9v5so9HVvHjfkQznM
34
36
  droidrun/agent/utils/message_utils.py,sha256=_wngf082gg232y_3pC_yn4fnPhHiyYAxhU4ewT78roo,2309
35
37
  droidrun/agent/utils/tools.py,sha256=anc10NAKmZx91JslHFpo6wfnUOZ2pnPXJS-5nMVHC_A,9930
36
38
  droidrun/agent/utils/trajectory.py,sha256=Z6C19Y9hsRxjLZWywqYWTApKU7PelvWM-5Tsl3h7KEw,19718
39
+ droidrun/app_cards/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
37
40
  droidrun/app_cards/app_card_provider.py,sha256=wy7CGFnBd_EPU58xNdv4ZWUA9F4Plon71N4-5RT5vNg,827
38
41
  droidrun/app_cards/providers/__init__.py,sha256=vN4TvBtsvfdvzgqbIJegIfHhct0aTFZjvJazWFDvdhg,372
39
42
  droidrun/app_cards/providers/composite_provider.py,sha256=oi7dlkv_Hv2rEZMxQlO1jP9fQcTBydr40zCyunCNxQA,3156
@@ -41,7 +44,7 @@ droidrun/app_cards/providers/local_provider.py,sha256=RRGQ7VR7qHT9uKSOlSvqCTRq_p
41
44
  droidrun/app_cards/providers/server_provider.py,sha256=rOJyiCE_zTCCK9SAJeee3vLWISytoZrBUiXB6LaJEv8,4148
42
45
  droidrun/cli/__init__.py,sha256=5cO-QBcUl5w35zO18OENj4OShdglQjn8Ne9aqgSh-PM,167
43
46
  droidrun/cli/logs.py,sha256=V8rn6oXgYObExX4dG8MUnQXxUdKOk1QlTkOQtI5e6wo,12686
44
- droidrun/cli/main.py,sha256=0-63OyiNlT944eOBqjw3fG8Edove88tqNEFkCKVHb8w,35372
47
+ droidrun/cli/main.py,sha256=lzGwWk8SbbxmVeyz2mqkPBT6Xs5SMxnQSxecuK-7L5s,35119
45
48
  droidrun/config_manager/__init__.py,sha256=SeLoEYVU5jMEtXLjx76VE_3rxzZXjCMlVPW7hodU128,460
46
49
  droidrun/config_manager/config_manager.py,sha256=hPETII_5wYvfb11e7sJlfCVk9p3WbA7nHPAV3bQQdmE,19930
47
50
  droidrun/config_manager/path_resolver.py,sha256=vQKT5XmnENtSK3B1D-iItL8CpOQTKzfKZ1wTO4khlTs,3421
@@ -55,11 +58,12 @@ droidrun/telemetry/events.py,sha256=y-i2d5KiPkikVXrzMQu87osy1LAZTBIx8DlPIWGAXG0,
55
58
  droidrun/telemetry/phoenix.py,sha256=JHdFdRHXu7cleAb4X4_Y5yn5zPSIApwyKCOxoaj_gf4,7117
56
59
  droidrun/telemetry/tracker.py,sha256=YWOkyLE8XiHainVSB77JE37y-rloOYVYs6j53Aw1J8A,2735
57
60
  droidrun/tools/__init__.py,sha256=BbQFKuPn-5MwGzr-3urMDK8S1ZsP96D96y7WTJYB3AA,271
58
- droidrun/tools/adb.py,sha256=ziMbQ02TDBvm04ffG1wlYUstsYABNopZaq0aOBh0xtA,42985
61
+ droidrun/tools/adb.py,sha256=PRbQS1qhy_HFUVx78LYPwTa4GT4TgMrRTwc7NM4Tf4A,28950
59
62
  droidrun/tools/ios.py,sha256=GMYbiNNBeHLwVQAo4_fEZ7snr4JCHE6sG11rcuPvSpk,21831
63
+ droidrun/tools/portal_client.py,sha256=BthC-ryHtCxh3czmkTge5aaurinPZfFkV4DgbkD_wbw,16307
60
64
  droidrun/tools/tools.py,sha256=0eAZFTaY10eiiUcJM4AkURmTGX-O1RRXjpQ5MHj2Ydo,5241
61
- droidrun-0.3.10.dev7.dist-info/METADATA,sha256=gac2d1ul1MhI-nSmx1ttR3XtWJoIhq6lIyZE4mufMRE,7044
62
- droidrun-0.3.10.dev7.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
63
- droidrun-0.3.10.dev7.dist-info/entry_points.txt,sha256=o259U66js8TIybQ7zs814Oe_LQ_GpZsp6a9Cr-xm5zE,51
64
- droidrun-0.3.10.dev7.dist-info/licenses/LICENSE,sha256=s-uxn9qChu-kFdRXUp6v_0HhsaJ_5OANmfNOFVm2zdk,1069
65
- droidrun-0.3.10.dev7.dist-info/RECORD,,
65
+ droidrun-0.3.10.dev9.dist-info/METADATA,sha256=YJPn9618Zy-2d_jo_46H45eSOi517Br1bxZ-7513_ms,7044
66
+ droidrun-0.3.10.dev9.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
67
+ droidrun-0.3.10.dev9.dist-info/entry_points.txt,sha256=o259U66js8TIybQ7zs814Oe_LQ_GpZsp6a9Cr-xm5zE,51
68
+ droidrun-0.3.10.dev9.dist-info/licenses/LICENSE,sha256=s-uxn9qChu-kFdRXUp6v_0HhsaJ_5OANmfNOFVm2zdk,1069
69
+ droidrun-0.3.10.dev9.dist-info/RECORD,,