uiautodev 0.4.0__tar.gz → 0.6.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 (33) hide show
  1. {uiautodev-0.4.0 → uiautodev-0.6.0}/PKG-INFO +6 -6
  2. {uiautodev-0.4.0 → uiautodev-0.6.0}/README.md +2 -0
  3. {uiautodev-0.4.0 → uiautodev-0.6.0}/pyproject.toml +3 -5
  4. {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/__init__.py +1 -1
  5. {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/app.py +25 -7
  6. {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/cli.py +1 -1
  7. {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/command_proxy.py +19 -10
  8. {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/command_types.py +1 -0
  9. uiautodev-0.6.0/uiautodev/common.py +54 -0
  10. {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/driver/android.py +25 -50
  11. {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/driver/base_driver.py +13 -4
  12. uiautodev-0.6.0/uiautodev/driver/harmony.py +224 -0
  13. {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/driver/ios.py +0 -3
  14. uiautodev-0.6.0/uiautodev/driver/testdata/layout.json +1 -0
  15. {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/model.py +11 -3
  16. {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/provider.py +18 -3
  17. {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/router/device.py +20 -8
  18. {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/utils/common.py +11 -7
  19. uiautodev-0.6.0/uiautodev/utils/envutils.py +9 -0
  20. uiautodev-0.4.0/uiautodev/common.py +0 -25
  21. {uiautodev-0.4.0 → uiautodev-0.6.0}/LICENSE +0 -0
  22. {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/__main__.py +0 -0
  23. {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/appium_proxy.py +0 -0
  24. {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/case.py +0 -0
  25. {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/driver/appium.py +0 -0
  26. {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/driver/mock.py +0 -0
  27. {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/driver/udt/appium-uiautomator2-v5.12.4-light.apk +0 -0
  28. {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/driver/udt/udt.py +0 -0
  29. {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/exceptions.py +0 -0
  30. {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/router/xml.py +0 -0
  31. {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/static/demo.html +0 -0
  32. {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/utils/exceptions.py +0 -0
  33. {uiautodev-0.4.0 → uiautodev-0.6.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.4.0
3
+ Version: 0.6.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,13 +13,11 @@ 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
- Provides-Extra: appium
16
+ Classifier: Programming Language :: Python :: 3.13
18
17
  Requires-Dist: adbutils (>=2.7.0,<3.0.0)
19
- Requires-Dist: appium-python-client (>=4.0.0,<5.0.0) ; extra == "appium"
20
18
  Requires-Dist: click (>=8.1.7,<9.0.0)
21
19
  Requires-Dist: construct
22
20
  Requires-Dist: fastapi (>=0.111.0,<0.112.0)
23
- Requires-Dist: httpretty (>=1.1.4,<2.0.0)
24
21
  Requires-Dist: httpx
25
22
  Requires-Dist: lxml
26
23
  Requires-Dist: pillow
@@ -30,6 +27,7 @@ Requires-Dist: pygments (>=2)
30
27
  Requires-Dist: uiautomator2 (>=2)
31
28
  Requires-Dist: uvicorn[standard]
32
29
  Requires-Dist: wdapy (>=0.2.2,<0.3.0)
30
+ Project-URL: Homepage, https://uiauto.dev
33
31
  Description-Content-Type: text/markdown
34
32
 
35
33
  # uiautodev
@@ -38,6 +36,8 @@ Description-Content-Type: text/markdown
38
36
 
39
37
  https://uiauto.dev
40
38
 
39
+ > backup site: https://uiauto.devsleep.com
40
+
41
41
  UI Inspector for Android and iOS, help inspector element properties, and auto generate XPath, script.
42
42
 
43
43
  # Install
@@ -4,6 +4,8 @@
4
4
 
5
5
  https://uiauto.dev
6
6
 
7
+ > backup site: https://uiauto.devsleep.com
8
+
7
9
  UI Inspector for Android and iOS, help inspector element properties, and auto generate XPath, script.
8
10
 
9
11
  # Install
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "uiautodev"
3
- version = "0.4.0"
3
+ version = "0.6.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>"]
@@ -15,8 +15,6 @@ construct = "*"
15
15
  lxml = "*"
16
16
  click = "^8.1.7"
17
17
  pygments = ">=2"
18
- httpretty = {version = "^1.1.4", optional = true}
19
- appium-python-client = {version = "^4.0.0", optional = true}
20
18
  uiautomator2 = ">=2"
21
19
  httpx = "*"
22
20
  fastapi = "^0.111.0"
@@ -25,8 +23,8 @@ poetry = "^1.8.2"
25
23
  pydantic = "^2.6"
26
24
  wdapy = "^0.2.2"
27
25
 
28
- [tool.poetry.extras]
29
- appium = ["appium-python-client", "httppretty"]
26
+ #[tool.poetry.extras]
27
+ #appium = ["appium-python-client", "httppretty"]
30
28
 
31
29
  [tool.poetry.scripts]
32
30
  "uiauto.dev" = "uiautodev.__main__:main"
@@ -5,4 +5,4 @@
5
5
  """
6
6
 
7
7
  # version is auto managed by poetry
8
- __version__ = "0.4.0"
8
+ __version__ = "0.6.0"
@@ -11,16 +11,19 @@ import signal
11
11
  from pathlib import Path
12
12
  from typing import List
13
13
 
14
- from fastapi import FastAPI
14
+ import uvicorn
15
+ from fastapi import FastAPI, File, UploadFile
15
16
  from fastapi.middleware.cors import CORSMiddleware
16
- from fastapi.responses import FileResponse, RedirectResponse
17
+ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
17
18
  from pydantic import BaseModel
18
19
 
19
20
  from uiautodev import __version__
20
- from uiautodev.common import get_webpage_url
21
- from uiautodev.provider import AndroidProvider, IOSProvider, MockProvider
21
+ from uiautodev.common import convert_bytes_to_image, get_webpage_url, ocr_image
22
+ from uiautodev.model import Node
23
+ from uiautodev.provider import AndroidProvider, HarmonyProvider, IOSProvider, MockProvider
22
24
  from uiautodev.router.device import make_router
23
25
  from uiautodev.router.xml import router as xml_router
26
+ from uiautodev.utils.envutils import Environment
24
27
 
25
28
  logger = logging.getLogger(__name__)
26
29
 
@@ -36,16 +39,19 @@ app.add_middleware(
36
39
 
37
40
  android_router = make_router(AndroidProvider())
38
41
  ios_router = make_router(IOSProvider())
42
+ harmony_router = make_router(HarmonyProvider())
39
43
  mock_router = make_router(MockProvider())
40
44
 
41
45
  app.include_router(mock_router, prefix="/api/mock", tags=["mock"])
42
46
 
43
- if os.environ.get("UIAUTODEV_MOCK"):
47
+ if Environment.UIAUTODEV_MOCK:
44
48
  app.include_router(mock_router, prefix="/api/android", tags=["mock"])
45
49
  app.include_router(mock_router, prefix="/api/ios", tags=["mock"])
50
+ app.include_router(mock_router, prefix="/api/harmony", tags=["mock"])
46
51
  else:
47
52
  app.include_router(android_router, prefix="/api/android", tags=["android"])
48
53
  app.include_router(ios_router, prefix="/api/ios", tags=["ios"])
54
+ app.include_router(harmony_router, prefix="/api/harmony", tags=["harmony"])
49
55
 
50
56
  app.include_router(xml_router, prefix="/api/xml", tags=["xml"])
51
57
 
@@ -68,10 +74,18 @@ def info() -> InfoResponse:
68
74
  platform=platform.system(), # Linux | Darwin | Windows
69
75
  code_language="Python",
70
76
  cwd=os.getcwd(),
71
- drivers=["android", "ios"],
77
+ drivers=["android", "ios", "harmony"],
72
78
  )
73
79
 
74
80
 
81
+ @app.post('/api/ocr_image')
82
+ async def _ocr_image(file: UploadFile = File(...)) -> List[Node]:
83
+ """OCR an image"""
84
+ image_data = await file.read()
85
+ image = convert_bytes_to_image(image_data)
86
+ return ocr_image(image)
87
+
88
+
75
89
  @app.get("/shutdown")
76
90
  def shutdown() -> str:
77
91
  """Shutdown the server"""
@@ -80,7 +94,7 @@ def shutdown() -> str:
80
94
 
81
95
 
82
96
  @app.get("/demo")
83
- def demo() -> str:
97
+ def demo():
84
98
  """Demo endpoint"""
85
99
  static_dir = Path(__file__).parent / "static"
86
100
  print(static_dir / "demo.html")
@@ -93,3 +107,7 @@ def index_redirect():
93
107
  url = get_webpage_url()
94
108
  logger.debug("redirect to %s", url)
95
109
  return RedirectResponse(url)
110
+
111
+
112
+ if __name__ == '__main__':
113
+ uvicorn.run("uiautodev.app:app", port=4000, reload=True, use_colors=True)
@@ -140,7 +140,7 @@ def self_update():
140
140
  @click.option("-f", "--force", is_flag=True, default=False, help="shutdown alrealy runningserver")
141
141
  @click.option("-s", "--no-browser", is_flag=True, default=False, help="silent mode, do not open browser")
142
142
  def server(port: int, host: str, reload: bool, force: bool, no_browser: bool):
143
- logger.info("version: %s", __version__)
143
+ print("uiautodev version:", __version__)
144
144
  if force:
145
145
  try:
146
146
  httpx.get(f"http://{host}:{port}/shutdown", timeout=3)
@@ -8,7 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  import time
10
10
  import typing
11
- from typing import Callable, Dict, Optional
11
+ from typing import Callable, Dict, List, Optional, Union
12
12
 
13
13
  from pydantic import BaseModel
14
14
 
@@ -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
20
+ from uiautodev.model import AppInfo, Node
21
21
  from uiautodev.utils.common import node_travel
22
22
 
23
23
  COMMANDS: Dict[Command, Callable] = {}
@@ -39,17 +39,21 @@ def get_command_params_type(command: Command) -> Optional[BaseModel]:
39
39
  return type_hints.get("params")
40
40
 
41
41
 
42
- def send_command(driver: BaseDriver, command: Command, params=None):
42
+ def send_command(driver: BaseDriver, command: Union[str, Command], params=None):
43
43
  if command not in COMMANDS:
44
44
  raise NotImplementedError(f"command {command} not implemented")
45
45
  func = COMMANDS[command]
46
- type_hints = typing.get_type_hints(func)
47
- if type_hints.get("params"):
46
+ params_model = get_command_params_type(command)
47
+ if params_model:
48
48
  if params is None:
49
49
  raise ValueError(f"params is required for {command}")
50
- if not isinstance(params, type_hints["params"]):
51
- raise TypeError(f"params should be {type_hints['params']}")
52
- if params is None:
50
+ if isinstance(params, dict):
51
+ params = params_model.model_validate(params)
52
+ elif isinstance(params, params_model):
53
+ pass
54
+ else:
55
+ raise TypeError(f"params should be {params_model}", params)
56
+ if not params:
53
57
  return func(driver)
54
58
  return func(driver, params)
55
59
 
@@ -57,8 +61,6 @@ def send_command(driver: BaseDriver, command: Command, params=None):
57
61
  @register(Command.TAP)
58
62
  def tap(driver: BaseDriver, params: TapRequest):
59
63
  """Tap on the screen
60
- :param x: x coordinate
61
- :param y: y coordinate
62
64
  """
63
65
  x = params.x
64
66
  y = params.y
@@ -177,3 +179,10 @@ def click_element(driver: BaseDriver, params: FindElementRequest):
177
179
  center_x = (node.bounds[0] + node.bounds[2]) / 2
178
180
  center_y = (node.bounds[1] + node.bounds[3]) / 2
179
181
  tap(driver, TapRequest(x=center_x, y=center_y, isPercent=True))
182
+
183
+
184
+ @register(Command.APP_LIST)
185
+ def app_list(driver: BaseDriver) -> List[AppInfo]:
186
+ # added in v0.5.0
187
+ return driver.app_list()
188
+
@@ -22,6 +22,7 @@ class Command(str, enum.Enum):
22
22
  APP_CURRENT = "currentApp"
23
23
  APP_LAUNCH = "appLaunch"
24
24
  APP_TERMINATE = "appTerminate"
25
+ APP_LIST = "appList"
25
26
 
26
27
  GET_WINDOW_SIZE = "getWindowSize"
27
28
  HOME = "home"
@@ -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
@@ -4,12 +4,11 @@
4
4
  """Created on Fri Mar 01 2024 14:19:29 by codeskyblue
5
5
  """
6
6
 
7
- import json
8
7
  import logging
9
8
  import re
10
9
  import time
11
10
  from functools import cached_property, partial
12
- from typing import List, Optional, Tuple
11
+ from typing import Iterator, List, Optional, Tuple
13
12
  from xml.etree import ElementTree
14
13
 
15
14
  import adbutils
@@ -18,9 +17,8 @@ from PIL import Image
18
17
 
19
18
  from uiautodev.command_types import CurrentAppResponse
20
19
  from uiautodev.driver.base_driver import BaseDriver
21
- from uiautodev.driver.udt.udt import UDT, UDTError
22
20
  from uiautodev.exceptions import AndroidDriverException, RequestError
23
- from uiautodev.model import Node, Rect, ShellResponse, WindowSize
21
+ from uiautodev.model import AppInfo, Node, Rect, ShellResponse, WindowSize
24
22
  from uiautodev.utils.common import fetch_through_socket
25
23
 
26
24
  logger = logging.getLogger(__name__)
@@ -29,28 +27,15 @@ class AndroidDriver(BaseDriver):
29
27
  def __init__(self, serial: str):
30
28
  super().__init__(serial)
31
29
  self.adb_device = adbutils.device(serial)
32
- self._try_dump_list = [
33
- self._get_u2_hierarchy,
34
- self._get_udt_dump_hierarchy,
35
- # self._get_appium_hierarchy,
36
- ]
37
-
38
- @cached_property
39
- def udt(self) -> UDT:
40
- return UDT(self.adb_device)
41
30
 
42
31
  @cached_property
43
32
  def ud(self) -> u2.Device:
44
33
  return u2.connect_usb(self.serial)
45
34
 
46
35
  def screenshot(self, id: int) -> Image.Image:
47
- try:
48
- return self.adb_device.screenshot() # display_id is not OK now
49
- except adbutils.AdbError as e:
50
- logger.warning("screenshot error: %s", str(e))
51
- if id > 0:
52
- raise AndroidDriverException("multi-display is not supported yet for uiautomator2")
53
- return self.ud.screenshot()
36
+ if id > 0:
37
+ raise AndroidDriverException("multi-display is not supported yet for uiautomator2")
38
+ return self.ud.screenshot()
54
39
 
55
40
  def shell(self, command: str) -> ShellResponse:
56
41
  try:
@@ -82,38 +67,12 @@ class AndroidDriver(BaseDriver):
82
67
 
83
68
  uiautomator dump errors:
84
69
  - ERROR: could not get idle state.
85
-
86
70
  """
87
- for dump_func in self._try_dump_list[:]:
88
- try:
89
- logger.debug(f"try to dump with %s", dump_func.__name__)
90
- result = dump_func()
91
- logger.debug("dump success")
92
- self._try_dump_list.remove(dump_func)
93
- self._try_dump_list.insert(0, dump_func)
94
- return result
95
- except Exception as e:
96
- logger.exception("unexpected dump error: %s", e)
97
- raise AndroidDriverException("Failed to dump hierarchy")
98
-
99
- def _get_u2_hierarchy(self) -> str:
100
- d = u2.connect_usb(self.serial)
101
- return d.dump_hierarchy()
102
-
103
- def _get_appium_hierarchy(self) -> str:
104
- c = self.adb_device.create_connection(adbutils.Network.TCP, 6790)
105
71
  try:
106
- content = fetch_through_socket(c, "/wd/hub/session/0/source", timeout=10)
107
- return json.loads(content)["value"]
108
- except (adbutils.AdbError, RequestError) as e:
109
- raise AndroidDriverException(
110
- f"Failed to get hierarchy from appium server: {str(e)}"
111
- )
112
- finally:
113
- c.close()
114
-
115
- def _get_udt_dump_hierarchy(self) -> str:
116
- return self.udt.dump_hierarchy()
72
+ return self.ud.dump_hierarchy()
73
+ except Exception as e:
74
+ logger.exception("unexpected dump error: %s", e)
75
+ raise AndroidDriverException("Failed to dump hierarchy")
117
76
 
118
77
  def tap(self, x: int, y: int):
119
78
  self.adb_device.click(x, y)
@@ -159,6 +118,22 @@ class AndroidDriver(BaseDriver):
159
118
 
160
119
  def volume_mute(self):
161
120
  self.adb_device.keyevent("VOLUME_MUTE")
121
+
122
+ def app_list(self) -> List[AppInfo]:
123
+ results = []
124
+ output = self.adb_device.shell(["pm", "list", "packages", '-3'])
125
+ for m in re.finditer(r"^package:([^\s]+)\r?$", output, re.M):
126
+ packageName = m.group(1)
127
+ results.append(AppInfo(packageName=packageName))
128
+ return results
129
+
130
+ def open_app_file(self, package: str) -> Iterator[bytes]:
131
+ line = self.adb_device.shell(f"pm path {package}")
132
+ if not line.startswith("package:"):
133
+ raise AndroidDriverException(f"Failed to get package path: {line}")
134
+ remote_path = line.split(':', 1)[1]
135
+ yield from self.adb_device.sync.iter_content(remote_path)
136
+
162
137
 
163
138
 
164
139
  def parse_xml(xml_data: str, wsize: WindowSize, display_id: Optional[int] = None) -> Node:
@@ -4,14 +4,14 @@
4
4
  """Created on Fri Mar 01 2024 14:18:30 by codeskyblue
5
5
  """
6
6
  import abc
7
- import enum
8
- from typing import Tuple
7
+ from io import FileIO
8
+ from typing import Iterator, List, Tuple
9
9
 
10
10
  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, ShellResponse, WindowSize
14
+ from uiautodev.model import AppInfo, Node, ShellResponse, WindowSize
15
15
 
16
16
 
17
17
  class BaseDriver(abc.ABC):
@@ -93,4 +93,13 @@ class BaseDriver(abc.ABC):
93
93
 
94
94
  def wake_up(self):
95
95
  """ wake up the device """
96
- raise NotImplementedError()
96
+ raise NotImplementedError()
97
+
98
+ def app_list(self) -> List[AppInfo]:
99
+ """ list installed packages """
100
+ raise NotImplementedError()
101
+
102
+ def open_app_file(self, package: str) -> Iterator[bytes]:
103
+ """ open app file """
104
+ raise NotImplementedError()
105
+
@@ -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