|
4 | 4 | import os |
5 | 5 | import shutil |
6 | 6 | import subprocess |
7 | | -import base64 |
8 | 7 | import json |
9 | 8 | from typing import Literal, Optional |
10 | | -import asyncio |
11 | | -import socket |
12 | 9 | import xml.etree.ElementTree as ET |
13 | 10 | import zipfile |
14 | 11 | import plistlib |
@@ -125,26 +122,91 @@ def get_ios_devices(): |
125 | 122 | return [] |
126 | 123 |
|
127 | 124 |
|
128 | | -@router.get("/inspect") |
129 | | -def inspect(device_serial: str | None = None): |
130 | | - """Get the Mobile DOM and screenshot.""" |
| 125 | +def run_adb_command_bytes(cmd: str, timeout: int = 30) -> bytes: |
| 126 | + """ |
| 127 | + Run an adb command and return stdout as raw bytes. |
| 128 | +
|
| 129 | + - cmd: full command string (example: 'adb -s SERIAL exec-out screencap -p') |
| 130 | + - timeout: seconds |
| 131 | +
|
| 132 | + Raises RuntimeError if adb fails. |
| 133 | + """ |
131 | 134 | try: |
132 | | - # Capture UI and screenshot |
133 | | - capture_ui_dump(device_serial=device_serial) |
134 | | - capture_screenshot(device_serial=device_serial) |
| 135 | + # Use shell=True ONLY if you are passing a full string with quotes |
| 136 | + # (like the sh -c command). For normal adb commands, shell=False is better. |
| 137 | + # |
| 138 | + # Since your combined command uses quotes heavily, we keep shell=True. |
| 139 | + p = subprocess.run( |
| 140 | + cmd, |
| 141 | + stdout=subprocess.PIPE, |
| 142 | + stderr=subprocess.PIPE, |
| 143 | + timeout=timeout, |
| 144 | + shell=True, |
| 145 | + ) |
135 | 146 |
|
136 | | - # Read XML file |
137 | | - with open(UI_XML_PATH, "r") as xml_file: |
138 | | - xml_content = xml_file.read() |
| 147 | + if p.returncode != 0: |
| 148 | + err = p.stderr.decode("utf-8", errors="replace").strip() |
| 149 | + raise RuntimeError(f"ADB command failed ({p.returncode}): {err}") |
139 | 150 |
|
140 | | - # Read and encode screenshot |
141 | | - with open(SCREENSHOT_PATH, "rb") as img_file: |
142 | | - screenshot_bytes = img_file.read() |
143 | | - screenshot_base64 = base64.b64encode(screenshot_bytes).decode("utf-8") |
| 151 | + return p.stdout |
| 152 | + |
| 153 | + except subprocess.TimeoutExpired: |
| 154 | + raise RuntimeError(f"ADB command timed out after {timeout}s: {cmd}") |
| 155 | + |
| 156 | + |
| 157 | +def fetch_xml_and_screenshot(device_serial: str | None = None) -> tuple[str, bytes]: |
| 158 | + """ |
| 159 | + Single-function fetch. Primary path uses ONE adb exec-out command to capture |
| 160 | + UI XML + PNG (base64) in a single stream. Falls back (still inside this function) |
| 161 | + if markers or outputs are invalid. |
| 162 | + """ |
| 163 | + device_flag = f"-s {device_serial}" if device_serial else "" |
| 164 | + |
| 165 | + SPLIT = "__ZEUZ_SPLIT__" |
| 166 | + END = "__ZEUZ_END__" |
| 167 | + |
| 168 | + cmd = ( |
| 169 | + f'{ADB_PATH} {device_flag} exec-out sh -c ' |
| 170 | + f'"uiautomator dump /dev/tty; ' |
| 171 | + f'echo {SPLIT}; ' |
| 172 | + f'screencap -p | base64; ' |
| 173 | + f'echo {END}"' |
| 174 | + ).strip() |
| 175 | + |
| 176 | + out = run_adb_command_bytes(cmd) |
| 177 | + |
| 178 | + text = out.decode("utf-8", errors="replace") |
| 179 | + if SPLIT in text and END in text: |
| 180 | + xml_part, rest = text.split(SPLIT, 1) |
| 181 | + b64_part = rest.split(END, 1)[0] |
| 182 | + |
| 183 | + # Extract real XML starting from <hierarchy |
| 184 | + i = xml_part.find("<hierarchy") |
| 185 | + xml = xml_part[i:] if i != -1 else "" |
| 186 | + |
| 187 | + # Decode PNG (ignore newlines/spaces) |
| 188 | + b64_compact = "".join(b64_part.split()) |
| 189 | + try: |
| 190 | + png = base64.b64decode(b64_compact, validate=False) |
| 191 | + except Exception: |
| 192 | + png = b"" |
| 193 | + |
| 194 | + if "<hierarchy" in xml and png.startswith(b"\x89PNG"): |
| 195 | + return xml, png |
| 196 | + |
| 197 | + |
| 198 | +@router.get("/inspect", response_model=InspectorResponse) |
| 199 | +async def inspect(device_serial: str | None = None): |
| 200 | + """Get the Mobile DOM and screenshot (XML + screenshot fetched together).""" |
| 201 | + global stop_android_ui_dump |
| 202 | + try: |
| 203 | + xml, png = await asyncio.to_thread(fetch_xml_and_screenshot, device_serial) |
| 204 | + if not xml or not png: |
| 205 | + return InspectorResponse(status="error", error="Failed to capture xml/screenshot") |
| 206 | + |
| 207 | + screenshot_base64 = base64.b64encode(png).decode("utf-8") |
| 208 | + return InspectorResponse(status="ok", ui_xml=xml, screenshot=screenshot_base64) |
144 | 209 |
|
145 | | - return InspectorResponse( |
146 | | - status="ok", ui_xml=xml_content, screenshot=screenshot_base64 |
147 | | - ) |
148 | 210 | except Exception as e: |
149 | 211 | return InspectorResponse(status="error", error=str(e)) |
150 | 212 |
|
@@ -259,7 +321,6 @@ def run_adb_command(command): |
259 | 321 | result = subprocess.run( |
260 | 322 | command, |
261 | 323 | shell=True, |
262 | | - check=True, |
263 | 324 | stdout=subprocess.PIPE, |
264 | 325 | stderr=subprocess.PIPE, |
265 | 326 | text=True, |
@@ -412,7 +473,7 @@ async def upload_android_ui_dump(): |
412 | 473 | prev_xml_hash = "" |
413 | 474 | while True: |
414 | 475 | try: |
415 | | - capture_ui_dump() |
| 476 | + await asyncio.to_thread(capture_ui_dump) |
416 | 477 | try: |
417 | 478 | with open(UI_XML_PATH, "r") as xml_file: |
418 | 479 | xml_content = xml_file.read() |
@@ -440,13 +501,15 @@ async def upload_android_ui_dump(): |
440 | 501 | + "/node_ai_contents/" |
441 | 502 | ) |
442 | 503 | apiKey = ConfigModule.get_config_value("Authentication", "api-key").strip() |
443 | | - res = requests.post( |
| 504 | + res = await asyncio.to_thread( |
| 505 | + requests.post, |
444 | 506 | url, |
445 | 507 | headers={"X-Api-Key": apiKey}, |
446 | 508 | json={ |
447 | 509 | "dom_mob": {"dom": xml_content}, |
448 | 510 | "node_id": CommonUtil.MachineInfo().getLocalUser().lower(), |
449 | 511 | }, |
| 512 | + timeout=10, |
450 | 513 | ) |
451 | 514 | if res.ok: |
452 | 515 | CommonUtil.ExecLog("", "UI dump uploaded successfully", iLogLevel=1) |
|
0 commit comments