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.
- {uiautodev-0.5.0 → uiautodev-0.7.0}/PKG-INFO +17 -4
- {uiautodev-0.5.0 → uiautodev-0.7.0}/README.md +12 -0
- {uiautodev-0.5.0 → uiautodev-0.7.0}/pyproject.toml +6 -2
- {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/__init__.py +1 -1
- uiautodev-0.7.0/uiautodev/app.py +158 -0
- uiautodev-0.7.0/uiautodev/binaries/scrcpy_server.jar +0 -0
- {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/cli.py +6 -22
- {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/command_proxy.py +1 -3
- uiautodev-0.7.0/uiautodev/common.py +54 -0
- {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/driver/android.py +2 -3
- {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/driver/base_driver.py +1 -1
- uiautodev-0.7.0/uiautodev/driver/harmony.py +224 -0
- uiautodev-0.7.0/uiautodev/driver/testdata/layout.json +1 -0
- {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/model.py +6 -2
- {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/provider.py +18 -3
- uiautodev-0.7.0/uiautodev/remote/android_input.py +74 -0
- uiautodev-0.7.0/uiautodev/remote/keycode.py +350 -0
- uiautodev-0.7.0/uiautodev/remote/scrcpy.py +177 -0
- uiautodev-0.7.0/uiautodev/remote/touch_controller.py +123 -0
- {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/router/device.py +3 -2
- {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/utils/common.py +11 -7
- uiautodev-0.7.0/uiautodev/utils/envutils.py +9 -0
- uiautodev-0.5.0/uiautodev/app.py +0 -95
- uiautodev-0.5.0/uiautodev/common.py +0 -25
- {uiautodev-0.5.0 → uiautodev-0.7.0}/LICENSE +0 -0
- {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/__main__.py +0 -0
- {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/appium_proxy.py +0 -0
- {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/case.py +0 -0
- {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/command_types.py +0 -0
- {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/driver/appium.py +0 -0
- {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/driver/ios.py +0 -0
- {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/driver/mock.py +0 -0
- {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/driver/udt/appium-uiautomator2-v5.12.4-light.apk +0 -0
- {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/driver/udt/udt.py +0 -0
- {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/exceptions.py +0 -0
- {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/router/xml.py +0 -0
- {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/static/demo.html +0 -0
- {uiautodev-0.5.0 → uiautodev-0.7.0}/uiautodev/utils/exceptions.py +0 -0
- {uiautodev-0.5.0 → uiautodev-0.7.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.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
|
-
|
|
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.
|
|
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 = "
|
|
17
|
+
adbutils = ">=2.8.10,<3"
|
|
14
18
|
construct = "*"
|
|
15
19
|
lxml = "*"
|
|
16
20
|
click = "^8.1.7"
|
|
@@ -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)
|
|
Binary file
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|