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.
- {uiautodev-0.4.0 → uiautodev-0.6.0}/PKG-INFO +6 -6
- {uiautodev-0.4.0 → uiautodev-0.6.0}/README.md +2 -0
- {uiautodev-0.4.0 → uiautodev-0.6.0}/pyproject.toml +3 -5
- {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/__init__.py +1 -1
- {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/app.py +25 -7
- {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/cli.py +1 -1
- {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/command_proxy.py +19 -10
- {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/command_types.py +1 -0
- uiautodev-0.6.0/uiautodev/common.py +54 -0
- {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/driver/android.py +25 -50
- {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/driver/base_driver.py +13 -4
- uiautodev-0.6.0/uiautodev/driver/harmony.py +224 -0
- {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/driver/ios.py +0 -3
- uiautodev-0.6.0/uiautodev/driver/testdata/layout.json +1 -0
- {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/model.py +11 -3
- {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/provider.py +18 -3
- {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/router/device.py +20 -8
- {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/utils/common.py +11 -7
- uiautodev-0.6.0/uiautodev/utils/envutils.py +9 -0
- uiautodev-0.4.0/uiautodev/common.py +0 -25
- {uiautodev-0.4.0 → uiautodev-0.6.0}/LICENSE +0 -0
- {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/__main__.py +0 -0
- {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/appium_proxy.py +0 -0
- {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/case.py +0 -0
- {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/driver/appium.py +0 -0
- {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/driver/mock.py +0 -0
- {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/driver/udt/appium-uiautomator2-v5.12.4-light.apk +0 -0
- {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/driver/udt/udt.py +0 -0
- {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/exceptions.py +0 -0
- {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/router/xml.py +0 -0
- {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/static/demo.html +0 -0
- {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/utils/exceptions.py +0 -0
- {uiautodev-0.4.0 → uiautodev-0.6.0}/uiautodev/utils/usbmux.py +0 -0
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
2
|
Name: uiautodev
|
|
3
|
-
Version: 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
|
-
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "uiautodev"
|
|
3
|
-
version = "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"
|
|
@@ -11,16 +11,19 @@ import signal
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import List
|
|
13
13
|
|
|
14
|
-
|
|
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.
|
|
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
|
|
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()
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
if
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
|
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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
|