uiautodev 0.5.0__py3-none-any.whl → 0.6.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 +1 -1
- uiautodev/app.py +25 -7
- uiautodev/command_proxy.py +1 -3
- uiautodev/common.py +30 -1
- uiautodev/driver/android.py +1 -1
- uiautodev/driver/base_driver.py +1 -1
- uiautodev/driver/harmony.py +224 -0
- uiautodev/driver/testdata/layout.json +1 -0
- uiautodev/model.py +6 -2
- uiautodev/provider.py +18 -3
- uiautodev/router/device.py +1 -1
- uiautodev/utils/common.py +11 -7
- uiautodev/utils/envutils.py +9 -0
- {uiautodev-0.5.0.dist-info → uiautodev-0.6.0.dist-info}/METADATA +4 -3
- uiautodev-0.6.0.dist-info/RECORD +33 -0
- {uiautodev-0.5.0.dist-info → uiautodev-0.6.0.dist-info}/WHEEL +1 -1
- uiautodev-0.5.0.dist-info/RECORD +0 -30
- {uiautodev-0.5.0.dist-info → uiautodev-0.6.0.dist-info}/LICENSE +0 -0
- {uiautodev-0.5.0.dist-info → uiautodev-0.6.0.dist-info}/entry_points.txt +0 -0
uiautodev/__init__.py
CHANGED
uiautodev/app.py
CHANGED
|
@@ -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)
|
uiautodev/command_proxy.py
CHANGED
|
@@ -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
|
|
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
|
uiautodev/driver/android.py
CHANGED
|
@@ -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
|
|
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__)
|
uiautodev/driver/base_driver.py
CHANGED
|
@@ -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
|
|
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
|