uiautodev 0.5.0__py3-none-any.whl → 0.7.0__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.

Potentially problematic release.


This version of uiautodev might be problematic. Click here for more details.

uiautodev/__init__.py CHANGED
@@ -5,4 +5,4 @@
5
5
  """
6
6
 
7
7
  # version is auto managed by poetry
8
- __version__ = "0.5.0"
8
+ __version__ = "0.7.0"
uiautodev/app.py CHANGED
@@ -11,21 +11,38 @@ import signal
11
11
  from pathlib import Path
12
12
  from typing import List
13
13
 
14
- from fastapi import FastAPI
14
+ import adbutils
15
+ import uvicorn
16
+ from fastapi import FastAPI, File, UploadFile, WebSocket
15
17
  from fastapi.middleware.cors import CORSMiddleware
16
- from fastapi.responses import FileResponse, RedirectResponse
18
+ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
17
19
  from pydantic import BaseModel
20
+ from rich.logging import RichHandler
21
+ from starlette.websockets import WebSocketDisconnect
18
22
 
19
23
  from uiautodev import __version__
20
- from uiautodev.common import get_webpage_url
21
- from uiautodev.provider import AndroidProvider, IOSProvider, MockProvider
24
+ from uiautodev.common import convert_bytes_to_image, get_webpage_url, ocr_image
25
+ from uiautodev.model import Node
26
+ from uiautodev.provider import AndroidProvider, HarmonyProvider, IOSProvider, MockProvider
27
+ from uiautodev.remote.scrcpy import ScrcpyServer
22
28
  from uiautodev.router.device import make_router
23
29
  from uiautodev.router.xml import router as xml_router
30
+ from uiautodev.utils.envutils import Environment
24
31
 
25
32
  logger = logging.getLogger(__name__)
26
33
 
27
34
  app = FastAPI()
28
35
 
36
+
37
+ def enable_logger_to_console():
38
+ _logger = logging.getLogger("uiautodev")
39
+ _logger.setLevel(logging.DEBUG)
40
+ _logger.addHandler(RichHandler(enable_link_path=False))
41
+
42
+
43
+ if os.getenv("UIAUTODEV_DEBUG"):
44
+ enable_logger_to_console()
45
+
29
46
  app.add_middleware(
30
47
  CORSMiddleware,
31
48
  allow_origins=["*"],
@@ -36,16 +53,19 @@ app.add_middleware(
36
53
 
37
54
  android_router = make_router(AndroidProvider())
38
55
  ios_router = make_router(IOSProvider())
56
+ harmony_router = make_router(HarmonyProvider())
39
57
  mock_router = make_router(MockProvider())
40
58
 
41
59
  app.include_router(mock_router, prefix="/api/mock", tags=["mock"])
42
60
 
43
- if os.environ.get("UIAUTODEV_MOCK"):
61
+ if Environment.UIAUTODEV_MOCK:
44
62
  app.include_router(mock_router, prefix="/api/android", tags=["mock"])
45
63
  app.include_router(mock_router, prefix="/api/ios", tags=["mock"])
64
+ app.include_router(mock_router, prefix="/api/harmony", tags=["mock"])
46
65
  else:
47
66
  app.include_router(android_router, prefix="/api/android", tags=["android"])
48
67
  app.include_router(ios_router, prefix="/api/ios", tags=["ios"])
68
+ app.include_router(harmony_router, prefix="/api/harmony", tags=["harmony"])
49
69
 
50
70
  app.include_router(xml_router, prefix="/api/xml", tags=["xml"])
51
71
 
@@ -68,10 +88,18 @@ def info() -> InfoResponse:
68
88
  platform=platform.system(), # Linux | Darwin | Windows
69
89
  code_language="Python",
70
90
  cwd=os.getcwd(),
71
- drivers=["android", "ios"],
91
+ drivers=["android", "ios", "harmony"],
72
92
  )
73
93
 
74
94
 
95
+ @app.post('/api/ocr_image')
96
+ async def _ocr_image(file: UploadFile = File(...)) -> List[Node]:
97
+ """OCR an image"""
98
+ image_data = await file.read()
99
+ image = convert_bytes_to_image(image_data)
100
+ return ocr_image(image)
101
+
102
+
75
103
  @app.get("/shutdown")
76
104
  def shutdown() -> str:
77
105
  """Shutdown the server"""
@@ -80,7 +108,7 @@ def shutdown() -> str:
80
108
 
81
109
 
82
110
  @app.get("/demo")
83
- def demo() -> str:
111
+ def demo():
84
112
  """Demo endpoint"""
85
113
  static_dir = Path(__file__).parent / "static"
86
114
  print(static_dir / "demo.html")
@@ -93,3 +121,38 @@ def index_redirect():
93
121
  url = get_webpage_url()
94
122
  logger.debug("redirect to %s", url)
95
123
  return RedirectResponse(url)
124
+
125
+
126
+ def get_scrcpy_server(serial: str):
127
+ # 这里主要是为了避免两次websocket建立建立,启动两个scrcpy进程
128
+ logger.info("create scrcpy server for %s", serial)
129
+ device = adbutils.device(serial)
130
+ return ScrcpyServer(device)
131
+
132
+
133
+ @app.websocket("/ws/android/scrcpy/{serial}")
134
+ async def unified_ws(websocket: WebSocket, serial: str):
135
+ """
136
+ Args:
137
+ serial: device serial
138
+ websocket: WebSocket
139
+ """
140
+ await websocket.accept()
141
+
142
+ try:
143
+ logger.info(f"WebSocket serial: {serial}")
144
+
145
+ # 获取 ScrcpyServer 实例
146
+ server = get_scrcpy_server(serial)
147
+ await server.handle_unified_websocket(websocket, serial)
148
+ except WebSocketDisconnect:
149
+ logger.info(f"WebSocket disconnected by client.")
150
+ except Exception as e:
151
+ logger.exception(f"WebSocket error for serial={serial}: {e}")
152
+ await websocket.close(code=1000, reason=str(e))
153
+ finally:
154
+ logger.info(f"WebSocket closed for serial={serial}")
155
+
156
+
157
+ if __name__ == '__main__':
158
+ uvicorn.run("uiautodev.app:app", port=4000, reload=True, use_colors=True)
Binary file
uiautodev/cli.py CHANGED
@@ -7,6 +7,7 @@
7
7
  from __future__ import annotations
8
8
 
9
9
  import logging
10
+ import os
10
11
  import platform
11
12
  import subprocess
12
13
  import sys
@@ -29,30 +30,12 @@ logger = logging.getLogger(__name__)
29
30
 
30
31
  CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
31
32
 
33
+
32
34
  @click.group(context_settings=CONTEXT_SETTINGS)
33
35
  @click.option("--verbose", "-v", is_flag=True, default=False, help="verbose mode")
34
36
  def cli(verbose: bool):
35
37
  if verbose:
36
- # try to enable logger is not very easy
37
- # you have to setup logHandler(logFormatter) for the root logger
38
- # and set all children logger to DEBUG
39
- # that's why it is not easy to use it with logging
40
- root_logger = logging.getLogger(__name__.split(".")[0])
41
- root_logger.setLevel(logging.DEBUG)
42
-
43
- console_handler = logging.StreamHandler()
44
- console_handler.setLevel(logging.DEBUG)
45
-
46
- formatter = logging.Formatter('%(name)s - %(levelname)s - %(message)s')
47
- console_handler.setFormatter(formatter)
48
-
49
- root_logger.addHandler(console_handler)
50
-
51
- # set all children logger to DEBUG
52
- for k in root_logger.manager.loggerDict.keys():
53
- if k.startswith(root_logger.name+"."):
54
- logging.getLogger(k).setLevel(logging.DEBUG)
55
-
38
+ os.environ['UIAUTODEV_DEBUG'] = '1'
56
39
  logger.debug("Verbose mode enabled")
57
40
 
58
41
 
@@ -113,7 +96,7 @@ def case():
113
96
  def appium(command: Command, params: list[str] = None):
114
97
  from uiautodev.driver.appium import AppiumProvider
115
98
  from uiautodev.exceptions import AppiumDriverException
116
-
99
+
117
100
  provider = AppiumProvider()
118
101
  try:
119
102
  run_driver_command(provider, command, params)
@@ -150,7 +133,7 @@ def server(port: int, host: str, reload: bool, force: bool, no_browser: bool):
150
133
  use_color = True
151
134
  if platform.system() == 'Windows':
152
135
  use_color = False
153
-
136
+
154
137
  if not no_browser:
155
138
  th = threading.Thread(target=open_browser_when_server_start, args=(f"http://{host}:{port}",))
156
139
  th.daemon = True
@@ -171,6 +154,7 @@ def open_browser_when_server_start(server_url: str):
171
154
  logger.info("open browser: %s", web_url)
172
155
  webbrowser.open(web_url)
173
156
 
157
+
174
158
  def main():
175
159
  # set logger level to INFO
176
160
  # logging.basicConfig(level=logging.INFO)
@@ -17,7 +17,7 @@ from uiautodev.command_types import AppLaunchRequest, AppTerminateRequest, By, C
17
17
  WindowSizeResponse
18
18
  from uiautodev.driver.base_driver import BaseDriver
19
19
  from uiautodev.exceptions import ElementNotFoundError
20
- from uiautodev.model import Node, AppInfo
20
+ from uiautodev.model import AppInfo, Node
21
21
  from uiautodev.utils.common import node_travel
22
22
 
23
23
  COMMANDS: Dict[Command, Callable] = {}
@@ -61,8 +61,6 @@ def send_command(driver: BaseDriver, command: Union[str, Command], params=None):
61
61
  @register(Command.TAP)
62
62
  def tap(driver: BaseDriver, params: TapRequest):
63
63
  """Tap on the screen
64
- :param x: x coordinate
65
- :param y: y coordinate
66
64
  """
67
65
  x = params.x
68
66
  y = params.y
uiautodev/common.py CHANGED
@@ -5,8 +5,16 @@
5
5
  """
6
6
 
7
7
 
8
+ import io
8
9
  import locale
10
+ import logging
11
+ from typing import List
9
12
 
13
+ from PIL import Image
14
+
15
+ from uiautodev.model import Node, OCRNode
16
+
17
+ logger = logging.getLogger(__name__)
10
18
 
11
19
  def is_chinese_language() -> bool:
12
20
  language_code, _ = locale.getdefaultlocale()
@@ -22,4 +30,25 @@ def get_webpage_url() -> str:
22
30
  web_url = "https://uiauto.dev"
23
31
  if is_chinese_language():
24
32
  web_url = "https://uiauto.devsleep.com"
25
- return web_url
33
+ return web_url
34
+
35
+
36
+ def convert_bytes_to_image(byte_data: bytes) -> Image.Image:
37
+ return Image.open(io.BytesIO(byte_data))
38
+
39
+
40
+ def ocr_image(image: Image.Image) -> List[OCRNode]:
41
+ # Placeholder for OCR implementation
42
+ w, h = image.size
43
+ try:
44
+ from ocrmac import ocrmac
45
+ except ImportError:
46
+ logger.error("OCR is not supported on this platform")
47
+ return []
48
+ result = ocrmac.OCR(image).recognize()
49
+ nodes = []
50
+ for index, (text, confidence, pbounds) in enumerate(result):
51
+ print(f"OCR result: {text}, confidence: {confidence}, bounds: {pbounds}")
52
+ # bounds = int(pbounds[0]*w), int(pbounds[1]*h), int(pbounds[2]*w), int(pbounds[3]*h)
53
+ nodes.append(OCRNode(key=str(index), name=text, bounds=pbounds, confidence=confidence))
54
+ return nodes
@@ -18,7 +18,7 @@ from PIL import Image
18
18
  from uiautodev.command_types import CurrentAppResponse
19
19
  from uiautodev.driver.base_driver import BaseDriver
20
20
  from uiautodev.exceptions import AndroidDriverException, RequestError
21
- from uiautodev.model import Node, AppInfo, Rect, ShellResponse, WindowSize
21
+ from uiautodev.model import AppInfo, Node, Rect, ShellResponse, WindowSize
22
22
  from uiautodev.utils.common import fetch_through_socket
23
23
 
24
24
  logger = logging.getLogger(__name__)
@@ -71,8 +71,7 @@ class AndroidDriver(BaseDriver):
71
71
  try:
72
72
  return self.ud.dump_hierarchy()
73
73
  except Exception as e:
74
- logger.exception("unexpected dump error: %s", e)
75
- raise AndroidDriverException("Failed to dump hierarchy")
74
+ raise AndroidDriverException(f"Failed to dump hierarchy: {str(e)}")
76
75
 
77
76
  def tap(self, x: int, y: int):
78
77
  self.adb_device.click(x, y)
@@ -11,7 +11,7 @@ from PIL import Image
11
11
  from pydantic import BaseModel
12
12
 
13
13
  from uiautodev.command_types import CurrentAppResponse
14
- from uiautodev.model import Node, AppInfo, ShellResponse, WindowSize
14
+ from uiautodev.model import AppInfo, Node, ShellResponse, WindowSize
15
15
 
16
16
 
17
17
  class BaseDriver(abc.ABC):
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ import json
4
+ import logging
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import tempfile
10
+ import time
11
+ import uuid
12
+ from pathlib import Path
13
+ from typing import List, Optional, Tuple, Union, final
14
+
15
+ from PIL import Image
16
+
17
+ from uiautodev.command_types import CurrentAppResponse
18
+ from uiautodev.driver.base_driver import BaseDriver
19
+ from uiautodev.model import AppInfo, Node, Rect, ShellResponse, WindowSize
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ StrOrPath = Union[str, Path]
24
+
25
+
26
+ def run_command(command: str, timeout: int = 60) -> str:
27
+ try:
28
+ result = subprocess.run(
29
+ command,
30
+ shell=True,
31
+ capture_output=True,
32
+ timeout=timeout,
33
+ text=True,
34
+ input='' # this avoid stdout: "FreeChannelContinue handle->data is nullptr"
35
+ )
36
+ # the hdc shell stderr is (不仅没啥用,还没办法去掉)
37
+ # Remote PTY will not be allocated because stdin is not a terminal.
38
+ # Use multiple -t options to force remote PTY allocation.
39
+ output = result.stdout.strip()
40
+ return output
41
+ except subprocess.TimeoutExpired as e:
42
+ raise TimeoutError(f"{command:r} timeout {e}")
43
+
44
+
45
+ class HDCError(Exception):
46
+ pass
47
+
48
+
49
+ class HDC:
50
+ def __init__(self):
51
+ self.hdc = 'hdc'
52
+ self.tmpdir = tempfile.TemporaryDirectory()
53
+
54
+ def __del__(self):
55
+ self.tmpdir.cleanup()
56
+
57
+ def list_device(self) -> List[str]:
58
+ command = f"{self.hdc} list targets"
59
+ result = run_command(command)
60
+ if result and not "Empty" in result:
61
+ devices = []
62
+ for line in result.strip().split("\n"):
63
+ serial = line.strip().split('\t', 1)[0]
64
+ devices.append(serial)
65
+ return devices
66
+ else:
67
+ return []
68
+
69
+ def shell(self, serial: str, command: str) -> str:
70
+ command = f"{self.hdc} -t {serial} shell \"{command}\""
71
+ result = run_command(command)
72
+ return result.strip()
73
+
74
+ def get_model(self, serial: str) -> str:
75
+ return self.shell(serial, "param get const.product.model")
76
+
77
+ def pull(self, serial: str, remote: StrOrPath, local: StrOrPath):
78
+ if isinstance(remote, Path):
79
+ remote = remote.as_posix()
80
+ command = f"{self.hdc} -t {serial} file recv {remote} {local}"
81
+ output = run_command(command)
82
+ if not os.path.exists(local):
83
+ raise HDCError(f"device file: {remote} not found", output)
84
+
85
+ def push(self, serial: str, local: StrOrPath, remote: StrOrPath) -> str:
86
+ if isinstance(remote, Path):
87
+ remote = remote.as_posix()
88
+ command = f"{self.hdc} -t {serial} file send {local} {remote}"
89
+ return run_command(command)
90
+
91
+ def screenshot(self, serial: str) -> Image.Image:
92
+ device_path = f'/data/local/tmp/screenshot-{int(time.time()*1000)}.png'
93
+ self.shell(serial, f"uitest screenCap -p {device_path}")
94
+ try:
95
+ local_path = os.path.join(self.tmpdir.name, f"{uuid.uuid4()}.png")
96
+ self.pull(serial, device_path, local_path)
97
+ with Image.open(local_path) as image:
98
+ image.load()
99
+ return image
100
+ finally:
101
+ self.shell(serial, f"rm {device_path}")
102
+
103
+ def dump_layout(self, serial: str) -> dict:
104
+ name = "{}.json".format(int(time.time() * 1000))
105
+ remote_path = f"/data/local/tmp/layout-{name}.json"
106
+ temp_path = os.path.join(self.tmpdir.name, f"layout-{name}.json")
107
+ output = self.shell(serial, f"uitest dumpLayout -p {remote_path}")
108
+ self.pull(serial, remote_path, temp_path)
109
+ # mock
110
+ # temp_path = Path(__file__).parent / 'testdata/layout.json'
111
+ try:
112
+ with open(temp_path, "rb") as f:
113
+ json_content = json.load(f)
114
+ return json_content
115
+ except json.JSONDecodeError:
116
+ raise HDCError(f"failed to dump layout: {output}")
117
+ finally:
118
+ self.shell(serial, f"rm {remote_path}")
119
+
120
+
121
+ class HarmonyDriver(BaseDriver):
122
+ def __init__(self, hdc: HDC, serial: str):
123
+ super().__init__(serial)
124
+ self.hdc = hdc
125
+
126
+ def screenshot(self, id: int = 0) -> Image.Image:
127
+ return self.hdc.screenshot(self.serial)
128
+
129
+ def window_size(self) -> WindowSize:
130
+ result = self.hdc.shell(self.serial, "hidumper -s 10 -a screen")
131
+ pattern = r"activeMode:\s*(\d+x\d+)"
132
+ match = re.search(pattern, result)
133
+ if match:
134
+ resolution = match.group(1).split("x")
135
+ return WindowSize(width=int(resolution[0]), height=int(resolution[1]))
136
+ else:
137
+ image = self.screenshot()
138
+ return WindowSize(width=image.width, height=image.height)
139
+
140
+ def dump_hierarchy(self) -> Tuple[str, Node]:
141
+ """returns xml string and hierarchy object"""
142
+ layout = self.hdc.dump_layout(self.serial)
143
+ return json.dumps(layout), parse_json_element(layout)
144
+
145
+ def tap(self, x: int, y: int):
146
+ self.hdc.shell(self.serial, f"uinput -T -c {x} {y}")
147
+
148
+ def app_current(self) -> Optional[CurrentAppResponse]:
149
+ echo = self.hdc.shell(self.serial, "hidumper -s WindowManagerService -a '-a'")
150
+ focus_window = re.search(r"Focus window: (\d+)", echo)
151
+ if focus_window:
152
+ focus_window = focus_window.group(1)
153
+ mission_echo = self.hdc.shell(self.serial, "aa dump -a")
154
+ pkg_names = re.findall(r"Mission ID #(\d+)\s+mission name #\[(.*?)\]", mission_echo)
155
+ if focus_window and pkg_names:
156
+ for mission in pkg_names:
157
+ mission_id = mission[0]
158
+ if focus_window == mission_id:
159
+ mission_name = mission[1]
160
+ pkg_name = mission_name.split(":")[0].replace("#", "")
161
+ ability_name = mission_name.split(":")[-1]
162
+ pid = self.hdc.shell(self.serial, f"pidof {pkg_name}").strip()
163
+ return CurrentAppResponse(package=pkg_name, activity=ability_name, pid=int(pid))
164
+ else:
165
+ return None
166
+
167
+ def shell(self, command: str) -> ShellResponse:
168
+ result = self.hdc.shell(self.serial, command)
169
+ return ShellResponse(output=result)
170
+
171
+ def home(self):
172
+ self.hdc.shell(self.serial, "uinput -K -d 1 -u 1")
173
+
174
+ def back(self):
175
+ self.hdc.shell(self.serial, "uinput -K -d 2 -u 2")
176
+
177
+ def volume_up(self):
178
+ self.hdc.shell(self.serial, "uinput -K -d 16 -u 16")
179
+
180
+ def volume_down(self):
181
+ self.hdc.shell(self.serial, "uinput -K -d 17 -u 17")
182
+
183
+ def volume_mute(self):
184
+ self.hdc.shell(self.serial, "uinput -K -d 22 -u 22")
185
+
186
+ def app_switch(self):
187
+ self.hdc.shell(self.serial, "uinput -K -d 2076 -d 2049 -u 2076 -u 2049")
188
+
189
+ def app_list(self) -> List[AppInfo]:
190
+ results = []
191
+ output = self.hdc.shell(self.serial, "bm dump -a")
192
+ for i in output.split("\n"):
193
+ if "ID" in i:
194
+ continue
195
+ else:
196
+ results.append(AppInfo(packageName=i.strip()))
197
+ return results
198
+
199
+
200
+ def parse_json_element(element, indexes: List[int] = [0]) -> Node:
201
+ """
202
+ Recursively parse an json element into a dictionary format.
203
+ """
204
+ attributes = element.get("attributes", {})
205
+ name = attributes.get("type", "")
206
+ bounds = attributes.get("bounds", "")
207
+ bounds = list(map(int, re.findall(r"\d+", bounds)))
208
+ assert len(bounds) == 4
209
+ rect = Rect(x=bounds[0], y=bounds[1], width=bounds[2] - bounds[0], height=bounds[3] - bounds[1])
210
+ elem = Node(
211
+ key="-".join(map(str, indexes)),
212
+ name=name,
213
+ bounds=None,
214
+ rect=rect,
215
+ properties={key: attributes[key] for key in attributes},
216
+ children=[],
217
+ )
218
+ # Construct xpath for children
219
+ for index, child in enumerate(element.get("children", [])):
220
+ child_node = parse_json_element(child, indexes + [index])
221
+ if child_node:
222
+ elem.children.append(child_node)
223
+
224
+ return elem