uiautodev 0.8.0__tar.gz → 0.10.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.8.0 → uiautodev-0.10.0}/PKG-INFO +11 -28
- {uiautodev-0.8.0 → uiautodev-0.10.0}/README.md +9 -26
- {uiautodev-0.8.0 → uiautodev-0.10.0}/pyproject.toml +2 -2
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/__init__.py +1 -1
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/app.py +43 -11
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/cli.py +22 -2
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/driver/android.py +37 -2
- uiautodev-0.10.0/uiautodev/exceptions.py +26 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/model.py +4 -2
- uiautodev-0.10.0/uiautodev/remote/harmony_mjpeg.py +199 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/remote/scrcpy.py +12 -10
- uiautodev-0.10.0/uiautodev/router/proxy.py +58 -0
- uiautodev-0.8.0/uiautodev/exceptions.py +0 -32
- {uiautodev-0.8.0 → uiautodev-0.10.0}/LICENSE +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/__main__.py +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/appium_proxy.py +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/binaries/scrcpy_server.jar +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/case.py +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/command_proxy.py +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/command_types.py +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/common.py +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/driver/appium.py +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/driver/base_driver.py +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/driver/harmony.py +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/driver/ios.py +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/driver/mock.py +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/driver/testdata/layout.json +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/driver/udt/appium-uiautomator2-v5.12.4-light.apk +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/driver/udt/udt.py +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/provider.py +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/remote/android_input.py +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/remote/keycode.py +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/remote/touch_controller.py +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/router/android.py +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/router/device.py +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/router/xml.py +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/static/demo.html +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/utils/common.py +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/utils/envutils.py +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/utils/exceptions.py +0 -0
- {uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/utils/usbmux.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: uiautodev
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.0
|
|
4
4
|
Summary: Mobile UI Automation, include UI hierarchy inspector, script recorder
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: codeskyblue
|
|
@@ -18,7 +18,7 @@ Requires-Dist: Pillow
|
|
|
18
18
|
Requires-Dist: adbutils (>=2.8.10,<3)
|
|
19
19
|
Requires-Dist: click (>=8.1.7,<9.0.0)
|
|
20
20
|
Requires-Dist: construct
|
|
21
|
-
Requires-Dist: fastapi (
|
|
21
|
+
Requires-Dist: fastapi (>=0.115.12,<1)
|
|
22
22
|
Requires-Dist: httpx
|
|
23
23
|
Requires-Dist: lxml
|
|
24
24
|
Requires-Dist: pydantic (>=2.6,<3.0)
|
|
@@ -38,15 +38,21 @@ Description-Content-Type: text/markdown
|
|
|
38
38
|
|
|
39
39
|
https://uiauto.dev
|
|
40
40
|
|
|
41
|
-
>
|
|
41
|
+
> In China visit: https://uiauto.devsleep.com
|
|
42
42
|
|
|
43
|
-
UI Inspector for Android and
|
|
43
|
+
UI Inspector for Android, iOS and Harmony help inspector element properties, and auto generate XPath, script.
|
|
44
44
|
|
|
45
45
|
# Install
|
|
46
46
|
```bash
|
|
47
47
|
pip install uiautodev
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
+
To enable Harmony support, run the following command to install its dependencies:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
uiautodev install-harmony
|
|
54
|
+
```
|
|
55
|
+
|
|
50
56
|
# Usage
|
|
51
57
|
```bash
|
|
52
58
|
Usage: uiauto.dev [OPTIONS] COMMAND [ARGS]...
|
|
@@ -70,31 +76,8 @@ uiauto.dev
|
|
|
70
76
|
```
|
|
71
77
|
|
|
72
78
|
# DEVELOP
|
|
73
|
-
```bash
|
|
74
|
-
# install poetry (python package manager)
|
|
75
|
-
pip install poetry # pipx install poetry
|
|
76
|
-
|
|
77
|
-
# install deps
|
|
78
|
-
poetry install
|
|
79
|
-
|
|
80
|
-
# format import
|
|
81
|
-
make format
|
|
82
79
|
|
|
83
|
-
|
|
84
|
-
make dev
|
|
85
|
-
|
|
86
|
-
# If you encounter the error NameError: name 'int2byte' is not defined,
|
|
87
|
-
# try installing a stable version of the construct package to resolve it:
|
|
88
|
-
# and restart: make dev
|
|
89
|
-
pip install construct==2.9.45
|
|
90
|
-
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
运行测试
|
|
94
|
-
|
|
95
|
-
```sh
|
|
96
|
-
make test
|
|
97
|
-
```
|
|
80
|
+
see [DEVELOP.md](DEVELOP.md)
|
|
98
81
|
|
|
99
82
|
# Links
|
|
100
83
|
- https://app.tangoapp.dev/ 基于webadb的手机远程控制项目
|
|
@@ -4,15 +4,21 @@
|
|
|
4
4
|
|
|
5
5
|
https://uiauto.dev
|
|
6
6
|
|
|
7
|
-
>
|
|
7
|
+
> In China visit: https://uiauto.devsleep.com
|
|
8
8
|
|
|
9
|
-
UI Inspector for Android and
|
|
9
|
+
UI Inspector for Android, iOS and Harmony help inspector element properties, and auto generate XPath, script.
|
|
10
10
|
|
|
11
11
|
# Install
|
|
12
12
|
```bash
|
|
13
13
|
pip install uiautodev
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
+
To enable Harmony support, run the following command to install its dependencies:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
uiautodev install-harmony
|
|
20
|
+
```
|
|
21
|
+
|
|
16
22
|
# Usage
|
|
17
23
|
```bash
|
|
18
24
|
Usage: uiauto.dev [OPTIONS] COMMAND [ARGS]...
|
|
@@ -36,31 +42,8 @@ uiauto.dev
|
|
|
36
42
|
```
|
|
37
43
|
|
|
38
44
|
# DEVELOP
|
|
39
|
-
```bash
|
|
40
|
-
# install poetry (python package manager)
|
|
41
|
-
pip install poetry # pipx install poetry
|
|
42
|
-
|
|
43
|
-
# install deps
|
|
44
|
-
poetry install
|
|
45
|
-
|
|
46
|
-
# format import
|
|
47
|
-
make format
|
|
48
45
|
|
|
49
|
-
|
|
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
|
|
63
|
-
```
|
|
46
|
+
see [DEVELOP.md](DEVELOP.md)
|
|
64
47
|
|
|
65
48
|
# Links
|
|
66
49
|
- https://app.tangoapp.dev/ 基于webadb的手机远程控制项目
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "uiautodev"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.10.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>"]
|
|
@@ -17,7 +17,7 @@ adbutils = ">=2.8.10,<3"
|
|
|
17
17
|
click = "^8.1.7"
|
|
18
18
|
pygments = ">=2"
|
|
19
19
|
uiautomator2 = ">=3.2.0,<4"
|
|
20
|
-
fastapi = "0.115.12"
|
|
20
|
+
fastapi = ">=0.115.12,<1"
|
|
21
21
|
pydantic = "^2.6"
|
|
22
22
|
wdapy = ">0.2.2,<1"
|
|
23
23
|
websockets = ">=10.4"
|
|
@@ -27,6 +27,7 @@ from uiautodev.provider import AndroidProvider, HarmonyProvider, IOSProvider, Mo
|
|
|
27
27
|
from uiautodev.remote.scrcpy import ScrcpyServer
|
|
28
28
|
from uiautodev.router.android import router as android_device_router
|
|
29
29
|
from uiautodev.router.device import make_router
|
|
30
|
+
from uiautodev.router.proxy import router as proxy_router
|
|
30
31
|
from uiautodev.router.xml import router as xml_router
|
|
31
32
|
from uiautodev.utils.envutils import Environment
|
|
32
33
|
|
|
@@ -70,7 +71,7 @@ else:
|
|
|
70
71
|
|
|
71
72
|
app.include_router(xml_router, prefix="/api/xml", tags=["xml"])
|
|
72
73
|
app.include_router(android_device_router, prefix="/api/android", tags=["android"])
|
|
73
|
-
|
|
74
|
+
app.include_router(proxy_router, prefix="/proxy", tags=["proxy"])
|
|
74
75
|
|
|
75
76
|
@app.get('/api/{platform}/features')
|
|
76
77
|
def get_features(platform: str) -> Dict[str, bool]:
|
|
@@ -87,6 +88,7 @@ def get_features(platform: str) -> Dict[str, bool]:
|
|
|
87
88
|
features[feature_name] = True
|
|
88
89
|
return features
|
|
89
90
|
|
|
91
|
+
|
|
90
92
|
class InfoResponse(BaseModel):
|
|
91
93
|
version: str
|
|
92
94
|
description: str
|
|
@@ -140,15 +142,41 @@ def index_redirect():
|
|
|
140
142
|
return RedirectResponse(url)
|
|
141
143
|
|
|
142
144
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
@app.websocket("/ws/android/scrcpy/{serial}")
|
|
146
|
+
async def handle_android_ws(websocket: WebSocket, serial: str):
|
|
147
|
+
"""
|
|
148
|
+
Args:
|
|
149
|
+
serial: device serial
|
|
150
|
+
websocket: WebSocket
|
|
151
|
+
"""
|
|
152
|
+
await websocket.accept()
|
|
148
153
|
|
|
154
|
+
try:
|
|
155
|
+
logger.info(f"WebSocket serial: {serial}")
|
|
156
|
+
device = adbutils.device(serial)
|
|
157
|
+
server = ScrcpyServer(device)
|
|
158
|
+
await server.handle_unified_websocket(websocket, serial)
|
|
159
|
+
except WebSocketDisconnect:
|
|
160
|
+
logger.info(f"WebSocket disconnected by client.")
|
|
161
|
+
except Exception as e:
|
|
162
|
+
logger.exception(f"WebSocket error for serial={serial}: {e}")
|
|
163
|
+
await websocket.close(code=1000, reason=str(e))
|
|
164
|
+
finally:
|
|
165
|
+
logger.info(f"WebSocket closed for serial={serial}")
|
|
149
166
|
|
|
150
|
-
|
|
151
|
-
|
|
167
|
+
|
|
168
|
+
def get_harmony_mjpeg_server(serial: str):
|
|
169
|
+
from hypium import UiDriver
|
|
170
|
+
|
|
171
|
+
from uiautodev.remote.harmony_mjpeg import HarmonyMjpegServer
|
|
172
|
+
driver = UiDriver.connect(device_sn=serial)
|
|
173
|
+
logger.info("create harmony mjpeg server for %s", serial)
|
|
174
|
+
logger.info(f'device wake_up_display: {driver.wake_up_display()}')
|
|
175
|
+
return HarmonyMjpegServer(driver)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@app.websocket("/ws/harmony/mjpeg/{serial}")
|
|
179
|
+
async def unified_harmony_ws(websocket: WebSocket, serial: str):
|
|
152
180
|
"""
|
|
153
181
|
Args:
|
|
154
182
|
serial: device serial
|
|
@@ -159,9 +187,13 @@ async def unified_ws(websocket: WebSocket, serial: str):
|
|
|
159
187
|
try:
|
|
160
188
|
logger.info(f"WebSocket serial: {serial}")
|
|
161
189
|
|
|
162
|
-
# 获取
|
|
163
|
-
server =
|
|
164
|
-
|
|
190
|
+
# 获取 HarmonyScrcpyServer 实例
|
|
191
|
+
server = get_harmony_mjpeg_server(serial)
|
|
192
|
+
server.start()
|
|
193
|
+
await server.handle_ws(websocket)
|
|
194
|
+
except ImportError as e:
|
|
195
|
+
logger.error(f"missing library for harmony: {e}")
|
|
196
|
+
await websocket.close(code=1000, reason="missing library, fix by \"pip install uiautodev[harmony]\"")
|
|
165
197
|
except WebSocketDisconnect:
|
|
166
198
|
logger.info(f"WebSocket disconnected by client.")
|
|
167
199
|
except Exception as e:
|
|
@@ -19,6 +19,7 @@ import click
|
|
|
19
19
|
import httpx
|
|
20
20
|
import pydantic
|
|
21
21
|
import uvicorn
|
|
22
|
+
from retry import retry
|
|
22
23
|
|
|
23
24
|
from uiautodev import __version__, command_proxy
|
|
24
25
|
from uiautodev.command_types import Command
|
|
@@ -29,7 +30,13 @@ from uiautodev.utils.common import convert_params_to_model, print_json
|
|
|
29
30
|
logger = logging.getLogger(__name__)
|
|
30
31
|
|
|
31
32
|
CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
|
|
32
|
-
|
|
33
|
+
HARMONY_PACKAGES = [
|
|
34
|
+
"setuptools",
|
|
35
|
+
"https://public.uiauto.devsleep.com/harmony/xdevice-5.0.7.200.tar.gz",
|
|
36
|
+
"https://public.uiauto.devsleep.com/harmony/xdevice-devicetest-5.0.7.200.tar.gz",
|
|
37
|
+
"https://public.uiauto.devsleep.com/harmony/xdevice-ohos-5.0.7.200.tar.gz",
|
|
38
|
+
"https://public.uiauto.devsleep.com/harmony/hypium-5.0.7.200.tar.gz",
|
|
39
|
+
]
|
|
33
40
|
|
|
34
41
|
@click.group(context_settings=CONTEXT_SETTINGS)
|
|
35
42
|
@click.option("--verbose", "-v", is_flag=True, default=False, help="verbose mode")
|
|
@@ -116,6 +123,19 @@ def self_update():
|
|
|
116
123
|
subprocess.run([sys.executable, '-m', "pip", "install", "--upgrade", "uiautodev"])
|
|
117
124
|
|
|
118
125
|
|
|
126
|
+
@cli.command('install-harmony')
|
|
127
|
+
def install_harmony():
|
|
128
|
+
for lib_url in HARMONY_PACKAGES:
|
|
129
|
+
click.echo(f"Installing {lib_url} ...")
|
|
130
|
+
pip_install(lib_url)
|
|
131
|
+
|
|
132
|
+
@retry(tries=2, delay=3, backoff=2)
|
|
133
|
+
def pip_install(package: str):
|
|
134
|
+
"""Install a package using pip."""
|
|
135
|
+
subprocess.run([sys.executable, '-m', "pip", "install", package], check=True)
|
|
136
|
+
click.echo(f"Successfully installed {package}")
|
|
137
|
+
|
|
138
|
+
|
|
119
139
|
@cli.command(help="start uiauto.dev local server [Default]")
|
|
120
140
|
@click.option("--port", default=20242, help="port number", show_default=True)
|
|
121
141
|
@click.option("--host", default="127.0.0.1", help="host", show_default=True)
|
|
@@ -123,7 +143,7 @@ def self_update():
|
|
|
123
143
|
@click.option("-f", "--force", is_flag=True, default=False, help="shutdown alrealy runningserver")
|
|
124
144
|
@click.option("-s", "--no-browser", is_flag=True, default=False, help="silent mode, do not open browser")
|
|
125
145
|
def server(port: int, host: str, reload: bool, force: bool, no_browser: bool):
|
|
126
|
-
|
|
146
|
+
click.echo(f"uiautodev version: {__version__}")
|
|
127
147
|
if force:
|
|
128
148
|
try:
|
|
129
149
|
httpx.get(f"http://{host}:{port}/shutdown", timeout=3)
|
|
@@ -126,13 +126,48 @@ class AndroidDriver(BaseDriver):
|
|
|
126
126
|
|
|
127
127
|
def volume_mute(self):
|
|
128
128
|
self.adb_device.keyevent("VOLUME_MUTE")
|
|
129
|
-
|
|
129
|
+
|
|
130
|
+
def get_app_version(self, package_name: str) -> Optional[dict]:
|
|
131
|
+
"""
|
|
132
|
+
Get the version information of an app, including mainVersion and subVersion.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
package_name (str): The package name of the app.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
dict: A dictionary containing mainVersion and subVersion.
|
|
139
|
+
"""
|
|
140
|
+
output = self.adb_device.shell(["dumpsys", "package", package_name])
|
|
141
|
+
|
|
142
|
+
# versionName
|
|
143
|
+
m = re.search(r"versionName=(?P<name>[^\s]+)", output)
|
|
144
|
+
version_name = m.group("name") if m else ""
|
|
145
|
+
if version_name == "null": # Java dumps "null" for null values
|
|
146
|
+
version_name = None
|
|
147
|
+
|
|
148
|
+
# versionCode
|
|
149
|
+
m = re.search(r"versionCode=(?P<code>\d+)", output)
|
|
150
|
+
version_code = m.group("code") if m else ""
|
|
151
|
+
version_code = int(version_code) if version_code.isdigit() else None
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
"versionName": version_name,
|
|
155
|
+
"versionCode": version_code
|
|
156
|
+
}
|
|
157
|
+
|
|
130
158
|
def app_list(self) -> List[AppInfo]:
|
|
131
159
|
results = []
|
|
132
160
|
output = self.adb_device.shell(["pm", "list", "packages", '-3'])
|
|
133
161
|
for m in re.finditer(r"^package:([^\s]+)\r?$", output, re.M):
|
|
134
162
|
packageName = m.group(1)
|
|
135
|
-
|
|
163
|
+
# get version
|
|
164
|
+
version_info = self.get_app_version(packageName)
|
|
165
|
+
app_info = AppInfo(
|
|
166
|
+
packageName=packageName,
|
|
167
|
+
versionName=version_info.get("versionName"),
|
|
168
|
+
versionCode=version_info.get("versionCode")
|
|
169
|
+
)
|
|
170
|
+
results.append(app_info)
|
|
136
171
|
return results
|
|
137
172
|
|
|
138
173
|
def open_app_file(self, package: str) -> Iterator[bytes]:
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""Created on Tue Mar 05 2024 11:16:29 by codeskyblue
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
class UiautoException(Exception):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class DriverException(UiautoException):
|
|
12
|
+
"""Base class for all driver-related exceptions."""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
class IOSDriverException(DriverException): ...
|
|
16
|
+
class AndroidDriverException(DriverException): ...
|
|
17
|
+
class HarmonyDriverException(DriverException): ...
|
|
18
|
+
class AppiumDriverException(DriverException): ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MethodError(UiautoException):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ElementNotFoundError(MethodError): ...
|
|
26
|
+
class RequestError(UiautoException): ...
|
|
@@ -33,7 +33,7 @@ class Rect(BaseModel):
|
|
|
33
33
|
|
|
34
34
|
class Node(BaseModel):
|
|
35
35
|
key: str
|
|
36
|
-
name: str
|
|
36
|
+
name: str # can be seen as description
|
|
37
37
|
bounds: Optional[Tuple[float, float, float, float]] = None
|
|
38
38
|
rect: Optional[Rect] = None
|
|
39
39
|
properties: Dict[str, Union[str, bool]] = {}
|
|
@@ -50,4 +50,6 @@ class WindowSize(typing.NamedTuple):
|
|
|
50
50
|
|
|
51
51
|
|
|
52
52
|
class AppInfo(BaseModel):
|
|
53
|
-
packageName: str
|
|
53
|
+
packageName: str
|
|
54
|
+
versionName: Optional[str] = None # Allow None values
|
|
55
|
+
versionCode: Optional[int] = None
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import socket
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from threading import Thread
|
|
7
|
+
|
|
8
|
+
from fastapi import WebSocket
|
|
9
|
+
from hypium import KeyCode
|
|
10
|
+
|
|
11
|
+
from uiautodev.exceptions import HarmonyDriverException
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class HarmonyMjpegServer:
|
|
17
|
+
"""
|
|
18
|
+
HarmonyMjpegServer is responsible for handling screen streaming functionality
|
|
19
|
+
for HarmonyOS devices that support ABC proxy (a communication interface).
|
|
20
|
+
|
|
21
|
+
It manages WebSocket clients, communicates with the ABC server over gRPC, and streams
|
|
22
|
+
the device's screen data in real-time to connected clients.
|
|
23
|
+
|
|
24
|
+
This server is specifically designed for devices running in 'abc mode' and requires that
|
|
25
|
+
the target device expose an `abc_proxy` attribute for communication.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
device: The HarmonyOS device object.
|
|
29
|
+
driver: The controlling driver which may wrap the device.
|
|
30
|
+
abc_rpc_addr: Tuple containing the IP and port used to communicate with abc_proxy.
|
|
31
|
+
channel: The gRPC communication channel (initialized later).
|
|
32
|
+
clients: A set of connected WebSocket clients.
|
|
33
|
+
loop: Asyncio event loop used to run asynchronous tasks.
|
|
34
|
+
is_running: Boolean flag indicating if the streaming service is active.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
RuntimeError: If the connected device does not support abc_proxy.
|
|
38
|
+
|
|
39
|
+
References:
|
|
40
|
+
- Huawei HarmonyOS Python Guidelines:
|
|
41
|
+
https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hypium-python-guidelines
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, driver):
|
|
45
|
+
if hasattr(driver, "_device"):
|
|
46
|
+
device = driver._device
|
|
47
|
+
else:
|
|
48
|
+
device = driver
|
|
49
|
+
logger.info(f'device: {device}')
|
|
50
|
+
if not hasattr(device, "abc_proxy") or device.abc_proxy is None:
|
|
51
|
+
raise HarmonyDriverException("Only abc mode can support screen recorder")
|
|
52
|
+
self.device = device
|
|
53
|
+
self.driver = driver
|
|
54
|
+
self.abc_rpc_addr = ("127.0.0.1", device.abc_proxy.port)
|
|
55
|
+
self.channel = None
|
|
56
|
+
self.clients = set()
|
|
57
|
+
self.loop = asyncio.get_event_loop()
|
|
58
|
+
self.is_running = False
|
|
59
|
+
|
|
60
|
+
def connect(self):
|
|
61
|
+
self.channel = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
62
|
+
self.channel.connect(self.abc_rpc_addr)
|
|
63
|
+
|
|
64
|
+
def start(self, timeout=3600):
|
|
65
|
+
if self.channel is None:
|
|
66
|
+
self.connect()
|
|
67
|
+
self.is_running = True
|
|
68
|
+
self.timeout = timeout
|
|
69
|
+
self.stop_capture_if_running()
|
|
70
|
+
msg_json = {'api': "startCaptureScreen", 'args': []}
|
|
71
|
+
full_msg = {
|
|
72
|
+
"module": "com.ohos.devicetest.hypiumApiHelper",
|
|
73
|
+
"method": "Captures",
|
|
74
|
+
"params": msg_json,
|
|
75
|
+
"request_id": datetime.now().strftime("%Y%m%d%H%M%S%f")
|
|
76
|
+
}
|
|
77
|
+
full_msg_str = json.dumps(full_msg, ensure_ascii=False, separators=(',', ':'))
|
|
78
|
+
self.channel.sendall(full_msg_str.encode("utf-8") + b'\n')
|
|
79
|
+
reply = self.channel.recv(1024)
|
|
80
|
+
logger.info(f'reply: {reply}')
|
|
81
|
+
if b"true" in reply:
|
|
82
|
+
thread_record = Thread(target=self._record_worker)
|
|
83
|
+
thread_record.start()
|
|
84
|
+
else:
|
|
85
|
+
raise RuntimeError("Fail to start screen capture")
|
|
86
|
+
|
|
87
|
+
def stop_capture_if_running(self):
|
|
88
|
+
msg_json = {'api': "stopCaptureScreen", 'args': []}
|
|
89
|
+
full_msg = {
|
|
90
|
+
"module": "com.ohos.devicetest.hypiumApiHelper",
|
|
91
|
+
"method": "Captures",
|
|
92
|
+
"params": msg_json,
|
|
93
|
+
"request_id": datetime.now().strftime("%Y%m%d%H%M%S%f")
|
|
94
|
+
}
|
|
95
|
+
full_msg_str = json.dumps(full_msg, ensure_ascii=False, separators=(',', ':'))
|
|
96
|
+
self.channel.sendall(full_msg_str.encode("utf-8") + b'\n')
|
|
97
|
+
reply = self.channel.recv(1024)
|
|
98
|
+
logger.info(f'stop reply: {reply}')
|
|
99
|
+
|
|
100
|
+
async def handle_ws(self, websocket: WebSocket):
|
|
101
|
+
self.clients.add(websocket)
|
|
102
|
+
serial = getattr(self.device, "device_sn", "unknown")
|
|
103
|
+
logger.info(f"[{serial}] WebSocket connected")
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
while True:
|
|
107
|
+
message = await websocket.receive_text()
|
|
108
|
+
logger.info(f"Received message: {message}")
|
|
109
|
+
try:
|
|
110
|
+
data = json.loads(message)
|
|
111
|
+
if data.get('type') == 'touch':
|
|
112
|
+
action = data.get('action')
|
|
113
|
+
x, y = data.get('x'), data.get('y')
|
|
114
|
+
if action == 'normal':
|
|
115
|
+
self.driver.touch((x, y))
|
|
116
|
+
elif action == 'long':
|
|
117
|
+
self.driver.touch(target=(x, y), mode='long')
|
|
118
|
+
elif action == 'double':
|
|
119
|
+
self.driver.touch(target=(x, y), mode='double')
|
|
120
|
+
elif action == 'move':
|
|
121
|
+
self.driver.slide(
|
|
122
|
+
start=(data.get('x1'), data.get('y1')),
|
|
123
|
+
end=(data.get('x2'), data.get('y2')),
|
|
124
|
+
slide_time=0.1
|
|
125
|
+
)
|
|
126
|
+
elif data.get('type') == 'keyEvent':
|
|
127
|
+
event_number = data['eventNumber']
|
|
128
|
+
if event_number == 187:
|
|
129
|
+
self.driver.swipe_to_recent_task()
|
|
130
|
+
elif event_number == 3:
|
|
131
|
+
self.driver.go_home()
|
|
132
|
+
elif event_number == 4:
|
|
133
|
+
self.driver.go_back()
|
|
134
|
+
elif event_number == 224:
|
|
135
|
+
self.driver.wake_up_display()
|
|
136
|
+
elif data.get('type') == 'text':
|
|
137
|
+
detail = data.get('detail')
|
|
138
|
+
if detail == 'CODE_AC_BACK':
|
|
139
|
+
self.driver.press_key(KeyCode.DEL)
|
|
140
|
+
elif detail == 'CODE_AC_ENTER':
|
|
141
|
+
self.driver.press_key(KeyCode.ENTER)
|
|
142
|
+
else:
|
|
143
|
+
self.driver.shell(
|
|
144
|
+
f"uitest uiInput inputText {data.get('x')} {data.get('y')} {detail}")
|
|
145
|
+
except Exception as e:
|
|
146
|
+
logger.warning(f"Failed to handle message: {e}")
|
|
147
|
+
except Exception as e:
|
|
148
|
+
logger.info(f"WebSocket closed: {e}")
|
|
149
|
+
finally:
|
|
150
|
+
self.clients.discard(websocket)
|
|
151
|
+
|
|
152
|
+
def _record_worker(self):
|
|
153
|
+
tmp_data = b''
|
|
154
|
+
start_flag = b'\xff\xd8'
|
|
155
|
+
end_flag = b'\xff\xd9'
|
|
156
|
+
while self.is_running:
|
|
157
|
+
try:
|
|
158
|
+
result = self.channel.recv(4096 * 1024)
|
|
159
|
+
tmp_data += result
|
|
160
|
+
while start_flag in tmp_data and end_flag in tmp_data:
|
|
161
|
+
start_index = tmp_data.index(start_flag)
|
|
162
|
+
end_index = tmp_data.index(end_flag) + 2
|
|
163
|
+
frame = tmp_data[start_index:end_index]
|
|
164
|
+
tmp_data = tmp_data[end_index:]
|
|
165
|
+
asyncio.run_coroutine_threadsafe(self._broadcast(frame), self.loop)
|
|
166
|
+
except Exception as e:
|
|
167
|
+
logger.warning(f"Record worker error: {e}")
|
|
168
|
+
self.is_running = False
|
|
169
|
+
self.channel = None
|
|
170
|
+
break
|
|
171
|
+
|
|
172
|
+
async def _broadcast(self, data):
|
|
173
|
+
for client in self.clients.copy():
|
|
174
|
+
try:
|
|
175
|
+
await client.send_bytes(data)
|
|
176
|
+
except Exception as e:
|
|
177
|
+
logger.info(f"Send error, removing client: {e}")
|
|
178
|
+
self.clients.discard(client)
|
|
179
|
+
|
|
180
|
+
def stop(self):
|
|
181
|
+
self.is_running = False
|
|
182
|
+
if self.channel is None:
|
|
183
|
+
return
|
|
184
|
+
msg_json = {'api': "stopCaptureScreen", 'args': []}
|
|
185
|
+
full_msg = {
|
|
186
|
+
"module": "com.ohos.devicetest.hypiumApiHelper",
|
|
187
|
+
"method": "Captures",
|
|
188
|
+
"params": msg_json,
|
|
189
|
+
"request_id": datetime.now().strftime("%Y%m%d%H%M%S%f")
|
|
190
|
+
}
|
|
191
|
+
full_msg_str = json.dumps(full_msg, ensure_ascii=False, separators=(',', ':'))
|
|
192
|
+
self.channel.sendall(full_msg_str.encode("utf-8") + b'\n')
|
|
193
|
+
reply = self.channel.recv(1024)
|
|
194
|
+
if b"true" not in reply:
|
|
195
|
+
logger.info("Fail to stop capture")
|
|
196
|
+
self.channel.close()
|
|
197
|
+
self.channel = None
|
|
198
|
+
for client in self.clients:
|
|
199
|
+
asyncio.run_coroutine_threadsafe(client.close(), self.loop)
|
|
@@ -16,6 +16,7 @@ from uiautodev.remote.touch_controller import ScrcpyTouchController
|
|
|
16
16
|
|
|
17
17
|
logger = logging.getLogger(__name__)
|
|
18
18
|
|
|
19
|
+
|
|
19
20
|
class ScrcpyServer:
|
|
20
21
|
"""
|
|
21
22
|
ScrcpyServer class is responsible for managing the scrcpy server on Android devices.
|
|
@@ -30,17 +31,18 @@ class ScrcpyServer:
|
|
|
30
31
|
Args:
|
|
31
32
|
scrcpy_jar_path (str, optional): Path to the scrcpy server JAR file. Defaults to None.
|
|
32
33
|
"""
|
|
33
|
-
self.scrcpy_jar_path = scrcpy_jar_path or os.path.join(os.path.dirname(__file__),
|
|
34
|
+
self.scrcpy_jar_path = scrcpy_jar_path or os.path.join(os.path.dirname(__file__),
|
|
35
|
+
'../binaries/scrcpy_server.jar')
|
|
34
36
|
self.device = device
|
|
35
37
|
self.resolution_width = 0 # scrcpy 投屏转换宽度
|
|
36
38
|
self.resolution_height = 0 # scrcpy 投屏转换高度
|
|
37
|
-
|
|
39
|
+
|
|
38
40
|
self._shell_conn: AdbConnection
|
|
39
41
|
self._video_conn: socket.socket
|
|
40
42
|
self._control_conn: socket.socket
|
|
41
43
|
|
|
42
44
|
self._setup_connection()
|
|
43
|
-
|
|
45
|
+
|
|
44
46
|
def _setup_connection(self):
|
|
45
47
|
self._shell_conn = self._start_scrcpy_server(control=True)
|
|
46
48
|
self._video_conn = self._connect_scrcpy(self.device)
|
|
@@ -73,7 +75,7 @@ class ScrcpyServer:
|
|
|
73
75
|
self._shell_conn.close()
|
|
74
76
|
except:
|
|
75
77
|
pass
|
|
76
|
-
|
|
78
|
+
|
|
77
79
|
def __del__(self):
|
|
78
80
|
self.close()
|
|
79
81
|
|
|
@@ -108,14 +110,14 @@ class ScrcpyServer:
|
|
|
108
110
|
)
|
|
109
111
|
conn = device.shell(start_command, stream=True)
|
|
110
112
|
logger.debug("scrcpy output: %s", conn.conn.recv(100))
|
|
111
|
-
return conn
|
|
113
|
+
return conn # type: ignore
|
|
112
114
|
|
|
113
115
|
async def handle_unified_websocket(self, websocket: WebSocket, serial=''):
|
|
114
116
|
logger.info(f"[Unified] WebSocket connection from {websocket} for serial: {serial}")
|
|
115
117
|
|
|
116
118
|
video_task = asyncio.create_task(self._stream_video_to_websocket(self._video_conn, websocket))
|
|
117
|
-
control_task
|
|
118
|
-
|
|
119
|
+
control_task = asyncio.create_task(self._handle_control_websocket(websocket))
|
|
120
|
+
|
|
119
121
|
try:
|
|
120
122
|
# 不使用 return_exceptions=True,让异常能够正确传播
|
|
121
123
|
await asyncio.gather(video_task, control_task)
|
|
@@ -129,14 +131,14 @@ class ScrcpyServer:
|
|
|
129
131
|
async def _stream_video_to_websocket(self, conn: socket.socket, ws: WebSocket):
|
|
130
132
|
# Set socket to non-blocking mode
|
|
131
133
|
conn.setblocking(False)
|
|
132
|
-
|
|
134
|
+
|
|
133
135
|
while True:
|
|
134
136
|
# check if ws closed
|
|
135
137
|
if ws.client_state.name != "CONNECTED":
|
|
136
138
|
logger.info('WebSocket no longer connected. Exiting video stream.')
|
|
137
139
|
break
|
|
138
140
|
# Use asyncio to read data asynchronously
|
|
139
|
-
data = await asyncio.get_event_loop().sock_recv(conn, 1024*1024)
|
|
141
|
+
data = await asyncio.get_event_loop().sock_recv(conn, 1024 * 1024)
|
|
140
142
|
if not data:
|
|
141
143
|
logger.warning('No data received, connection may be closed.')
|
|
142
144
|
raise ConnectionError("Video stream ended unexpectedly")
|
|
@@ -174,4 +176,4 @@ class ScrcpyServer:
|
|
|
174
176
|
await ws.send_text(json.dumps({"type": "pong"}))
|
|
175
177
|
except json.JSONDecodeError as e:
|
|
176
178
|
logger.error(f"Invalid JSON message: {e}")
|
|
177
|
-
continue
|
|
179
|
+
continue
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import websockets
|
|
5
|
+
from fastapi import APIRouter, HTTPException, Request, WebSocket, WebSocketDisconnect
|
|
6
|
+
from fastapi.responses import Response
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
router = APIRouter()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# HTTP 转发
|
|
15
|
+
@router.api_route("/http/{target_url:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
|
|
16
|
+
async def proxy_http(request: Request, target_url: str):
|
|
17
|
+
logger.info(f"HTTP target_url: {target_url}")
|
|
18
|
+
|
|
19
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
|
|
20
|
+
body = await request.body()
|
|
21
|
+
resp = await client.request(
|
|
22
|
+
request.method,
|
|
23
|
+
target_url,
|
|
24
|
+
content=body,
|
|
25
|
+
headers={k: v for k, v in request.headers.items() if k.lower() != "host" and k.lower() != "x-target-url"}
|
|
26
|
+
)
|
|
27
|
+
return Response(content=resp.content, status_code=resp.status_code, headers=dict(resp.headers))
|
|
28
|
+
|
|
29
|
+
# WebSocket 转发
|
|
30
|
+
@router.websocket("/ws/{target_url:path}")
|
|
31
|
+
async def proxy_ws(websocket: WebSocket, target_url: str):
|
|
32
|
+
await websocket.accept()
|
|
33
|
+
logger.info(f"WebSocket target_url: {target_url}")
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
async with websockets.connect(target_url) as target_ws:
|
|
37
|
+
async def from_client():
|
|
38
|
+
while True:
|
|
39
|
+
msg = await websocket.receive_text()
|
|
40
|
+
await target_ws.send(msg)
|
|
41
|
+
|
|
42
|
+
async def from_server():
|
|
43
|
+
while True:
|
|
44
|
+
msg = await target_ws.recv()
|
|
45
|
+
if isinstance(msg, bytes):
|
|
46
|
+
await websocket.send_bytes(msg)
|
|
47
|
+
elif isinstance(msg, str):
|
|
48
|
+
await websocket.send_text(msg)
|
|
49
|
+
else:
|
|
50
|
+
raise RuntimeError("Unknown message type", msg)
|
|
51
|
+
|
|
52
|
+
await asyncio.gather(from_client(), from_server())
|
|
53
|
+
|
|
54
|
+
except WebSocketDisconnect:
|
|
55
|
+
pass
|
|
56
|
+
except Exception as e:
|
|
57
|
+
logger.error(f"WS Error: {e}")
|
|
58
|
+
await websocket.close()
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
|
-
|
|
4
|
-
"""Created on Tue Mar 05 2024 11:16:29 by codeskyblue
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
class UiautoException(Exception):
|
|
8
|
-
pass
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class IOSDriverException(UiautoException):
|
|
12
|
-
pass
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class AndroidDriverException(UiautoException):
|
|
16
|
-
pass
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
class AppiumDriverException(UiautoException):
|
|
20
|
-
pass
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
class MethodError(UiautoException):
|
|
24
|
-
pass
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
class ElementNotFoundError(MethodError):
|
|
28
|
-
pass
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class RequestError(UiautoException):
|
|
32
|
-
pass
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{uiautodev-0.8.0 → uiautodev-0.10.0}/uiautodev/driver/udt/appium-uiautomator2-v5.12.4-light.apk
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|