uiautodev 0.5.0__tar.gz → 0.7.0__tar.gz

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.

Files changed (39) hide show
  1. {uiautodev-0.5.0 → uiautodev-0.7.0}/PKG-INFO +17 -4
  2. {uiautodev-0.5.0 → uiautodev-0.7.0}/README.md +12 -0
  3. {uiautodev-0.5.0 → uiautodev-0.7.0}/pyproject.toml +6 -2
  4. {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/__init__.py +1 -1
  5. uiautodev-0.7.0/uiautodev/app.py +158 -0
  6. uiautodev-0.7.0/uiautodev/binaries/scrcpy_server.jar +0 -0
  7. {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/cli.py +6 -22
  8. {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/command_proxy.py +1 -3
  9. uiautodev-0.7.0/uiautodev/common.py +54 -0
  10. {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/driver/android.py +2 -3
  11. {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/driver/base_driver.py +1 -1
  12. uiautodev-0.7.0/uiautodev/driver/harmony.py +224 -0
  13. uiautodev-0.7.0/uiautodev/driver/testdata/layout.json +1 -0
  14. {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/model.py +6 -2
  15. {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/provider.py +18 -3
  16. uiautodev-0.7.0/uiautodev/remote/android_input.py +74 -0
  17. uiautodev-0.7.0/uiautodev/remote/keycode.py +350 -0
  18. uiautodev-0.7.0/uiautodev/remote/scrcpy.py +177 -0
  19. uiautodev-0.7.0/uiautodev/remote/touch_controller.py +123 -0
  20. {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/router/device.py +3 -2
  21. {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/utils/common.py +11 -7
  22. uiautodev-0.7.0/uiautodev/utils/envutils.py +9 -0
  23. uiautodev-0.5.0/uiautodev/app.py +0 -95
  24. uiautodev-0.5.0/uiautodev/common.py +0 -25
  25. {uiautodev-0.5.0 → uiautodev-0.7.0}/LICENSE +0 -0
  26. {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/__main__.py +0 -0
  27. {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/appium_proxy.py +0 -0
  28. {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/case.py +0 -0
  29. {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/command_types.py +0 -0
  30. {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/driver/appium.py +0 -0
  31. {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/driver/ios.py +0 -0
  32. {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/driver/mock.py +0 -0
  33. {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/driver/udt/appium-uiautomator2-v5.12.4-light.apk +0 -0
  34. {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/driver/udt/udt.py +0 -0
  35. {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/exceptions.py +0 -0
  36. {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/router/xml.py +0 -0
  37. {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/static/demo.html +0 -0
  38. {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/utils/exceptions.py +0 -0
  39. {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/utils/usbmux.py +0 -0
@@ -1,8 +1,7 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: uiautodev
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: Mobile UI Automation, include UI hierarchy inspector, script recorder
5
- Home-page: https://uiauto.dev
6
5
  License: MIT
7
6
  Author: codeskyblue
8
7
  Author-email: codeskyblue@gmail.com
@@ -14,7 +13,8 @@ Classifier: Programming Language :: Python :: 3.9
14
13
  Classifier: Programming Language :: Python :: 3.10
15
14
  Classifier: Programming Language :: Python :: 3.11
16
15
  Classifier: Programming Language :: Python :: 3.12
17
- Requires-Dist: adbutils (>=2.7.0,<3.0.0)
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Requires-Dist: adbutils (>=2.8.10,<3)
18
18
  Requires-Dist: click (>=8.1.7,<9.0.0)
19
19
  Requires-Dist: construct
20
20
  Requires-Dist: fastapi (>=0.111.0,<0.112.0)
@@ -27,6 +27,7 @@ Requires-Dist: pygments (>=2)
27
27
  Requires-Dist: uiautomator2 (>=2)
28
28
  Requires-Dist: uvicorn[standard]
29
29
  Requires-Dist: wdapy (>=0.2.2,<0.3.0)
30
+ Project-URL: Homepage, https://uiauto.dev
30
31
  Description-Content-Type: text/markdown
31
32
 
32
33
  # uiautodev
@@ -79,6 +80,18 @@ make format
79
80
 
80
81
  # run server
81
82
  make dev
83
+
84
+ # If you encounter the error NameError: name 'int2byte' is not defined,
85
+ # try installing a stable version of the construct package to resolve it:
86
+ # and restart: make dev
87
+ pip install construct==2.9.45
88
+
89
+ ```
90
+
91
+ 运行测试
92
+
93
+ ```sh
94
+ make test
82
95
  ```
83
96
 
84
97
  # LICENSE
@@ -48,6 +48,18 @@ make format
48
48
 
49
49
  # run server
50
50
  make dev
51
+
52
+ # If you encounter the error NameError: name 'int2byte' is not defined,
53
+ # try installing a stable version of the construct package to resolve it:
54
+ # and restart: make dev
55
+ pip install construct==2.9.45
56
+
57
+ ```
58
+
59
+ 运行测试
60
+
61
+ ```sh
62
+ make test
51
63
  ```
52
64
 
53
65
  # LICENSE
@@ -1,16 +1,20 @@
1
1
  [tool.poetry]
2
2
  name = "uiautodev"
3
- version = "0.5.0"
3
+ version = "0.7.0"
4
4
  description = "Mobile UI Automation, include UI hierarchy inspector, script recorder"
5
5
  homepage = "https://uiauto.dev"
6
6
  authors = ["codeskyblue <codeskyblue@gmail.com>"]
7
7
  license = "MIT"
8
8
  readme = "README.md"
9
9
 
10
+ include = [
11
+ {path = "uiautodev/binaries/scrcpy.jar"}
12
+ ]
13
+
10
14
  [tool.poetry.dependencies]
11
15
  python = "^3.8"
12
16
  pillow = "*"
13
- adbutils = "^2.7.0"
17
+ adbutils = ">=2.8.10,<3"
14
18
  construct = "*"
15
19
  lxml = "*"
16
20
  click = "^8.1.7"
@@ -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"
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """Created on Sun Feb 18 2024 13:48:55 by codeskyblue
5
+ """
6
+
7
+ import logging
8
+ import os
9
+ import platform
10
+ import signal
11
+ from pathlib import Path
12
+ from typing import List
13
+
14
+ import adbutils
15
+ import uvicorn
16
+ from fastapi import FastAPI, File, UploadFile, WebSocket
17
+ from fastapi.middleware.cors import CORSMiddleware
18
+ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
19
+ from pydantic import BaseModel
20
+ from rich.logging import RichHandler
21
+ from starlette.websockets import WebSocketDisconnect
22
+
23
+ from uiautodev import __version__
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
28
+ from uiautodev.router.device import make_router
29
+ from uiautodev.router.xml import router as xml_router
30
+ from uiautodev.utils.envutils import Environment
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ app = FastAPI()
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
+
46
+ app.add_middleware(
47
+ CORSMiddleware,
48
+ allow_origins=["*"],
49
+ allow_credentials=True,
50
+ allow_methods=["GET", "POST"],
51
+ allow_headers=["*"],
52
+ )
53
+
54
+ android_router = make_router(AndroidProvider())
55
+ ios_router = make_router(IOSProvider())
56
+ harmony_router = make_router(HarmonyProvider())
57
+ mock_router = make_router(MockProvider())
58
+
59
+ app.include_router(mock_router, prefix="/api/mock", tags=["mock"])
60
+
61
+ if Environment.UIAUTODEV_MOCK:
62
+ app.include_router(mock_router, prefix="/api/android", tags=["mock"])
63
+ app.include_router(mock_router, prefix="/api/ios", tags=["mock"])
64
+ app.include_router(mock_router, prefix="/api/harmony", tags=["mock"])
65
+ else:
66
+ app.include_router(android_router, prefix="/api/android", tags=["android"])
67
+ app.include_router(ios_router, prefix="/api/ios", tags=["ios"])
68
+ app.include_router(harmony_router, prefix="/api/harmony", tags=["harmony"])
69
+
70
+ app.include_router(xml_router, prefix="/api/xml", tags=["xml"])
71
+
72
+
73
+ class InfoResponse(BaseModel):
74
+ version: str
75
+ description: str
76
+ platform: str
77
+ code_language: str
78
+ cwd: str
79
+ drivers: List[str]
80
+
81
+
82
+ @app.get("/api/info")
83
+ def info() -> InfoResponse:
84
+ """Information about the application"""
85
+ return InfoResponse(
86
+ version=__version__,
87
+ description="client for https://uiauto.dev",
88
+ platform=platform.system(), # Linux | Darwin | Windows
89
+ code_language="Python",
90
+ cwd=os.getcwd(),
91
+ drivers=["android", "ios", "harmony"],
92
+ )
93
+
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
+
103
+ @app.get("/shutdown")
104
+ def shutdown() -> str:
105
+ """Shutdown the server"""
106
+ os.kill(os.getpid(), signal.SIGINT)
107
+ return "Server shutting down..."
108
+
109
+
110
+ @app.get("/demo")
111
+ def demo():
112
+ """Demo endpoint"""
113
+ static_dir = Path(__file__).parent / "static"
114
+ print(static_dir / "demo.html")
115
+ return FileResponse(static_dir / "demo.html")
116
+
117
+
118
+ @app.get("/")
119
+ def index_redirect():
120
+ """ redirect to official homepage """
121
+ url = get_webpage_url()
122
+ logger.debug("redirect to %s", url)
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)
@@ -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
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """Created on Thu May 09 2024 11:33:17 by codeskyblue
5
+ """
6
+
7
+
8
+ import io
9
+ import locale
10
+ import logging
11
+ from typing import List
12
+
13
+ from PIL import Image
14
+
15
+ from uiautodev.model import Node, OCRNode
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ def is_chinese_language() -> bool:
20
+ language_code, _ = locale.getdefaultlocale()
21
+
22
+ # Check if the language code starts with 'zh' (Chinese)
23
+ if language_code and language_code.startswith('zh'):
24
+ return True
25
+ else:
26
+ return False
27
+
28
+
29
+ def get_webpage_url() -> str:
30
+ web_url = "https://uiauto.dev"
31
+ if is_chinese_language():
32
+ web_url = "https://uiauto.devsleep.com"
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