uiautodev 0.3.3__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 +12 -0
- uiautodev/__main__.py +10 -0
- uiautodev/app.py +92 -0
- uiautodev/appium_proxy.py +53 -0
- uiautodev/case.py +137 -0
- uiautodev/cli.py +171 -0
- uiautodev/command_proxy.py +154 -0
- uiautodev/command_types.py +89 -0
- uiautodev/driver/android.py +228 -0
- uiautodev/driver/appium.py +136 -0
- uiautodev/driver/base_driver.py +76 -0
- uiautodev/driver/ios.py +114 -0
- uiautodev/driver/mock.py +74 -0
- uiautodev/driver/udt/appium-uiautomator2-v5.12.4-light.apk +0 -0
- uiautodev/driver/udt/udt.py +259 -0
- uiautodev/exceptions.py +32 -0
- uiautodev/model.py +37 -0
- uiautodev/provider.py +76 -0
- uiautodev/router/device.py +104 -0
- uiautodev/router/xml.py +28 -0
- uiautodev/static/demo.html +34 -0
- uiautodev/utils/common.py +166 -0
- uiautodev/utils/exceptions.py +43 -0
- uiautodev/utils/usbmux.py +485 -0
- uiautodev-0.3.3.dist-info/LICENSE +21 -0
- uiautodev-0.3.3.dist-info/METADATA +56 -0
- uiautodev-0.3.3.dist-info/RECORD +29 -0
- uiautodev-0.3.3.dist-info/WHEEL +4 -0
- uiautodev-0.3.3.dist-info/entry_points.txt +4 -0
uiautodev/driver/mock.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""Created on Mon Mar 04 2024 14:10:00 by codeskyblue
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from PIL import Image, ImageDraw
|
|
8
|
+
|
|
9
|
+
from uiautodev.driver.base_driver import BaseDriver
|
|
10
|
+
from uiautodev.model import Node, ShellResponse, WindowSize
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MockDriver(BaseDriver):
|
|
14
|
+
def screenshot(self, id: int):
|
|
15
|
+
im = Image.new("RGB", (500, 800), "gray")
|
|
16
|
+
draw = ImageDraw.Draw(im)
|
|
17
|
+
draw.text((10, 10), "mock", fill="white")
|
|
18
|
+
draw.rectangle([100, 100, 200, 200], outline="red", fill="blue")
|
|
19
|
+
del draw
|
|
20
|
+
return im
|
|
21
|
+
|
|
22
|
+
def dump_hierarchy(self):
|
|
23
|
+
return "", Node(
|
|
24
|
+
key="0",
|
|
25
|
+
name="root",
|
|
26
|
+
bounds=(0, 0, 1, 1),
|
|
27
|
+
properties={
|
|
28
|
+
"class": "android.view.View",
|
|
29
|
+
},
|
|
30
|
+
children=[
|
|
31
|
+
Node(
|
|
32
|
+
key="0-0",
|
|
33
|
+
name="mock1",
|
|
34
|
+
bounds=(0.1, 0.1, 0.5, 0.5),
|
|
35
|
+
properties={
|
|
36
|
+
"class": "android.widget.FrameLayout",
|
|
37
|
+
"text": "mock1",
|
|
38
|
+
"accessible": "true",
|
|
39
|
+
},
|
|
40
|
+
),
|
|
41
|
+
Node(
|
|
42
|
+
key="0-1",
|
|
43
|
+
name="mock2",
|
|
44
|
+
bounds=(0.4, 0.4, 0.6, 0.6),
|
|
45
|
+
properties={
|
|
46
|
+
"class": "android.widget.ImageView",
|
|
47
|
+
"text": "mock2",
|
|
48
|
+
"accessible": "true",
|
|
49
|
+
},
|
|
50
|
+
children=[
|
|
51
|
+
Node(
|
|
52
|
+
key="0-1-0",
|
|
53
|
+
name="mock2-1",
|
|
54
|
+
bounds=(0.42, 0.42, 0.45, 0.45),
|
|
55
|
+
properties={
|
|
56
|
+
"class": "android.widget.ImageView",
|
|
57
|
+
"text": "mock2-1",
|
|
58
|
+
"visible": "true",
|
|
59
|
+
},
|
|
60
|
+
),
|
|
61
|
+
]
|
|
62
|
+
),
|
|
63
|
+
Node(
|
|
64
|
+
key="0-2",
|
|
65
|
+
name="mock-should-not-show",
|
|
66
|
+
bounds=(0.4, 0.4, 0.6, 0.6),
|
|
67
|
+
properties={
|
|
68
|
+
"class": "android.widget.ImageView",
|
|
69
|
+
"text": "mock3",
|
|
70
|
+
"visible": "false",
|
|
71
|
+
},
|
|
72
|
+
),
|
|
73
|
+
],
|
|
74
|
+
)
|
|
Binary file
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""Created on Sun Apr 21 2024 21:15:15 by codeskyblue
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
import atexit
|
|
9
|
+
from base64 import b64decode
|
|
10
|
+
import enum
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from pprint import pprint
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
|
|
20
|
+
from PIL import Image
|
|
21
|
+
import adbutils
|
|
22
|
+
import requests
|
|
23
|
+
from pydantic import BaseModel
|
|
24
|
+
|
|
25
|
+
"""
|
|
26
|
+
shell steps:
|
|
27
|
+
adb push appium-uiautomator2-v5.12.4.apk /data/local/tmp/udt.jar
|
|
28
|
+
adb shell CLASSPATH=/data/local/tmp/udt.jar app_process / "com.wetest.uia2.Main"
|
|
29
|
+
adb forward tcp:6790 tcp:6790
|
|
30
|
+
# 创建session
|
|
31
|
+
echo '{"capabilities": {}}' | http POST :6790/session
|
|
32
|
+
# 获取当前所有session
|
|
33
|
+
http GET :6790/sessions
|
|
34
|
+
# 获取pageSource
|
|
35
|
+
http GET :6790/session/{session_id}/source
|
|
36
|
+
|
|
37
|
+
# TODO
|
|
38
|
+
# /appium/settins 中waitForIdleTimeout需要调整,其他的再看看
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
class UDTError(Exception):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class HTTPError(UDTError):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AppiumErrorEnum(str, enum.Enum):
|
|
52
|
+
InvalidSessionID = 'invalid session id'
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class AppiumError(UDTError):
|
|
56
|
+
def __init__(self, error: str, message):
|
|
57
|
+
self.error = error
|
|
58
|
+
self.message = message
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AppiumResponseValue(BaseModel):
|
|
62
|
+
error: Optional[str] = None
|
|
63
|
+
message: Optional[str] = None
|
|
64
|
+
stacktrace: Optional[str] = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AppiumResponse(BaseModel):
|
|
68
|
+
sessionId: Optional[str] = None
|
|
69
|
+
value: Any = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class MockAdbProcess:
|
|
73
|
+
def __init__(self, conn: adbutils.AdbConnection) -> None:
|
|
74
|
+
self._conn = conn
|
|
75
|
+
self._event = threading.Event()
|
|
76
|
+
|
|
77
|
+
def wait_finished():
|
|
78
|
+
try:
|
|
79
|
+
self._conn.read_until_close()
|
|
80
|
+
except:
|
|
81
|
+
pass
|
|
82
|
+
self._event.set()
|
|
83
|
+
|
|
84
|
+
t = threading.Thread(target=wait_finished)
|
|
85
|
+
t.daemon = True
|
|
86
|
+
t.name = "wait_adb_conn"
|
|
87
|
+
t.start()
|
|
88
|
+
|
|
89
|
+
def wait(self) -> int:
|
|
90
|
+
self._event.wait()
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
def pool(self) -> Optional[int]:
|
|
94
|
+
if self._event.is_set():
|
|
95
|
+
return 0
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
def kill(self):
|
|
99
|
+
self._conn.close()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class UDT:
|
|
103
|
+
def __init__(self, device: adbutils.AdbDevice):
|
|
104
|
+
self._device = device
|
|
105
|
+
self._lport = None
|
|
106
|
+
self._process = None
|
|
107
|
+
self._lock = threading.Lock()
|
|
108
|
+
self._session_id = None
|
|
109
|
+
atexit.register(self.release)
|
|
110
|
+
|
|
111
|
+
def get_session_id(self) -> str:
|
|
112
|
+
if self._session_id:
|
|
113
|
+
return self._session_id
|
|
114
|
+
self._session_id = self._new_session()
|
|
115
|
+
logger.debug("update waitForIdleTimeout to 0ms")
|
|
116
|
+
self._dev_request("POST", f"/session/{self._session_id}/appium/settings", payload={
|
|
117
|
+
"settings": {
|
|
118
|
+
"waitForIdleTimeout": 10,
|
|
119
|
+
"waitForSelectorTimeout": 10,
|
|
120
|
+
"actionAcknowledgmentTimeout": 10,
|
|
121
|
+
"scrollAcknowledgmentTimeout": 10,
|
|
122
|
+
"trackScrollEvents": False,
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
result = self._dev_request("GET", f"/session/{self._session_id}/appium/settings")
|
|
126
|
+
return self._session_id
|
|
127
|
+
|
|
128
|
+
def dev_request(self, method: str, path: str, **kwargs) -> AppiumResponse:
|
|
129
|
+
"""send http request to device
|
|
130
|
+
:param method: GET, POST, DELETE, PUT
|
|
131
|
+
:param path: url path, path start with @ means with_session=True
|
|
132
|
+
|
|
133
|
+
:return: response json
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
if path.startswith("@"):
|
|
137
|
+
path = path[1:]
|
|
138
|
+
kwargs['with_session'] = True
|
|
139
|
+
return self._dev_request(method, path, **kwargs)
|
|
140
|
+
except HTTPError:
|
|
141
|
+
self.launch_server()
|
|
142
|
+
return self._dev_request(method, path, **kwargs)
|
|
143
|
+
except AppiumError as e:
|
|
144
|
+
if e.error == AppiumErrorEnum.InvalidSessionID:
|
|
145
|
+
self._session_id = self._new_session()
|
|
146
|
+
return self._dev_request(method, path, **kwargs)
|
|
147
|
+
raise
|
|
148
|
+
|
|
149
|
+
def _dev_request(self, method: str, path: str, payload=None, timeout: float = 10.0, with_session: bool = False) -> AppiumResponse:
|
|
150
|
+
try:
|
|
151
|
+
if with_session:
|
|
152
|
+
sid = self.get_session_id()
|
|
153
|
+
path = f"/session/{sid}{path}"
|
|
154
|
+
url = f"http://localhost:{self._lport}{path}"
|
|
155
|
+
logger.debug("request %s %s", method, url)
|
|
156
|
+
r = requests.request(method, url, json=payload, timeout=timeout)
|
|
157
|
+
response_json = r.json()
|
|
158
|
+
resp = AppiumResponse.model_validate(response_json)
|
|
159
|
+
if isinstance(resp.value, dict):
|
|
160
|
+
value = AppiumResponseValue.model_validate(resp.value)
|
|
161
|
+
if value.error:
|
|
162
|
+
raise AppiumError(value.error, value.message)
|
|
163
|
+
return resp
|
|
164
|
+
except requests.RequestException as e:
|
|
165
|
+
raise HTTPError(f"{method} to {path!r} error", payload)
|
|
166
|
+
except json.JSONDecodeError as e:
|
|
167
|
+
raise HTTPError("JSON decode error", e.msg)
|
|
168
|
+
|
|
169
|
+
def _new_session(self) -> str:
|
|
170
|
+
resp = self._dev_request("POST", "/session", payload={"capabilities": {}})
|
|
171
|
+
value = resp.value
|
|
172
|
+
if not isinstance(value, dict) and 'sessionId' not in value:
|
|
173
|
+
raise UDTError("session create failed", resp)
|
|
174
|
+
sid = value['sessionId']
|
|
175
|
+
if not sid:
|
|
176
|
+
raise UDTError("session create failed", resp)
|
|
177
|
+
return sid
|
|
178
|
+
|
|
179
|
+
def post(self, path: str, payload=None) -> AppiumResponse:
|
|
180
|
+
return self.dev_request("POST", path, payload=payload)
|
|
181
|
+
|
|
182
|
+
def get(self, path: str, ) -> AppiumResponse:
|
|
183
|
+
return self.dev_request("GET", path)
|
|
184
|
+
|
|
185
|
+
def _update_process_status(self):
|
|
186
|
+
if self._process:
|
|
187
|
+
if self._process.pool() is not None:
|
|
188
|
+
self._process = None
|
|
189
|
+
|
|
190
|
+
def release(self):
|
|
191
|
+
logger.debug("Releasing")
|
|
192
|
+
with self._lock:
|
|
193
|
+
if self._process is not None:
|
|
194
|
+
logger.debug("Killing process")
|
|
195
|
+
self._process.kill()
|
|
196
|
+
self._process.wait()
|
|
197
|
+
self._process = None
|
|
198
|
+
|
|
199
|
+
def launch_server(self):
|
|
200
|
+
try:
|
|
201
|
+
self._launch_server()
|
|
202
|
+
self._device.keyevent("WAKEUP")
|
|
203
|
+
except adbutils.AdbError as e:
|
|
204
|
+
raise UDTError("fail to start udt", str(e))
|
|
205
|
+
self._wait_ready()
|
|
206
|
+
|
|
207
|
+
def _launch_server(self):
|
|
208
|
+
with self._lock:
|
|
209
|
+
self._update_process_status()
|
|
210
|
+
if self._process:
|
|
211
|
+
logger.debug("Process already running")
|
|
212
|
+
return
|
|
213
|
+
logger.debug("Launching process")
|
|
214
|
+
dex_local_path = Path(__file__).parent.joinpath("appium-uiautomator2-v5.12.4-light.apk")
|
|
215
|
+
logger.debug("dex_local_path: %s", dex_local_path)
|
|
216
|
+
dex_remote_path = "/data/local/tmp/udt/udt-5.12.4-light.dex"
|
|
217
|
+
info = self._device.sync.stat(dex_remote_path)
|
|
218
|
+
if info.size == dex_local_path.stat().st_size:
|
|
219
|
+
logger.debug("%s already exists", dex_remote_path)
|
|
220
|
+
else:
|
|
221
|
+
logger.debug("push dex(%d) to %s", dex_local_path.stat().st_size, dex_remote_path)
|
|
222
|
+
self._device.shell("mkdir -p /data/local/tmp/udt")
|
|
223
|
+
self._device.sync.push(dex_local_path, dex_remote_path, 0o644)
|
|
224
|
+
logger.debug("CLASSPATH=%s app_process / com.wetest.uia2.Main", dex_remote_path)
|
|
225
|
+
conn = self._device.shell(f"CLASSPATH={dex_remote_path} app_process / com.wetest.uia2.Main", stream=True)
|
|
226
|
+
self._process = MockAdbProcess(conn)
|
|
227
|
+
|
|
228
|
+
self._lport = self._device.forward_port(6790)
|
|
229
|
+
logger.debug("forward tcp:6790 -> tcp:%d", self._lport)
|
|
230
|
+
|
|
231
|
+
def _wait_ready(self):
|
|
232
|
+
deadline = time.time() + 10
|
|
233
|
+
while time.time() < deadline:
|
|
234
|
+
try:
|
|
235
|
+
self._dev_request("GET", "/status", timeout=1)
|
|
236
|
+
return
|
|
237
|
+
except HTTPError:
|
|
238
|
+
time.sleep(0.5)
|
|
239
|
+
raise UDTError("Service not ready")
|
|
240
|
+
|
|
241
|
+
def dump_hierarchy(self) -> str:
|
|
242
|
+
resp = self.get(f"@/source")
|
|
243
|
+
return resp.value
|
|
244
|
+
|
|
245
|
+
def status(self):
|
|
246
|
+
return self.get("/status")
|
|
247
|
+
|
|
248
|
+
def screenshot(self) -> Image.Image:
|
|
249
|
+
resp = self.get(f"@/screenshot")
|
|
250
|
+
raw = b64decode(resp.value)
|
|
251
|
+
return Image.open(io.BytesIO(raw))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if __name__ == '__main__':
|
|
256
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
257
|
+
r = UDT(adbutils.device())
|
|
258
|
+
print(r.status())
|
|
259
|
+
r.dump_hierarchy()
|
uiautodev/exceptions.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
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
|
uiautodev/model.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""Created on Sun Feb 18 2024 11:12:33 by codeskyblue
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import typing
|
|
9
|
+
from typing import Dict, List, Optional, Tuple, Union
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DeviceInfo(BaseModel):
|
|
15
|
+
serial: str
|
|
16
|
+
model: str = ""
|
|
17
|
+
name: str = ""
|
|
18
|
+
status: str = ""
|
|
19
|
+
enabled: bool = True
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ShellResponse(BaseModel):
|
|
23
|
+
output: str
|
|
24
|
+
error: Optional[str] = ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Node(BaseModel):
|
|
28
|
+
key: str
|
|
29
|
+
name: str
|
|
30
|
+
bounds: Optional[Tuple[float, float, float, float]] = None
|
|
31
|
+
properties: Dict[str, Union[str, bool]] = []
|
|
32
|
+
children: List[Node] = []
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class WindowSize(typing.NamedTuple):
|
|
36
|
+
width: int
|
|
37
|
+
height: int
|
uiautodev/provider.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""Created on Sun Feb 18 2024 11:10:58 by codeskyblue
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import abc
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
|
|
11
|
+
import adbutils
|
|
12
|
+
|
|
13
|
+
from uiautodev.driver.android import AndroidDriver
|
|
14
|
+
from uiautodev.driver.base_driver import BaseDriver
|
|
15
|
+
from uiautodev.driver.ios import IOSDriver
|
|
16
|
+
from uiautodev.driver.mock import MockDriver
|
|
17
|
+
from uiautodev.exceptions import UiautoException
|
|
18
|
+
from uiautodev.model import DeviceInfo
|
|
19
|
+
from uiautodev.utils.usbmux import MuxDevice, list_devices
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BaseProvider(abc.ABC):
|
|
23
|
+
@abc.abstractmethod
|
|
24
|
+
def list_devices(self) -> list[DeviceInfo]:
|
|
25
|
+
raise NotImplementedError()
|
|
26
|
+
|
|
27
|
+
@abc.abstractmethod
|
|
28
|
+
def get_device_driver(self, serial: str) -> BaseDriver:
|
|
29
|
+
raise NotImplementedError()
|
|
30
|
+
|
|
31
|
+
def get_single_device_driver(self) -> BaseDriver:
|
|
32
|
+
""" debug use """
|
|
33
|
+
devs = self.list_devices()
|
|
34
|
+
if len(devs) == 0:
|
|
35
|
+
raise UiautoException("No device found")
|
|
36
|
+
if len(devs) > 1:
|
|
37
|
+
raise UiautoException("More than one device found")
|
|
38
|
+
return self.get_device_driver(devs[0].serial)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AndroidProvider(BaseProvider):
|
|
42
|
+
def __init__(self):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
def list_devices(self) -> list[DeviceInfo]:
|
|
46
|
+
adb = adbutils.AdbClient()
|
|
47
|
+
ret: list[DeviceInfo] = []
|
|
48
|
+
for d in adb.list():
|
|
49
|
+
if d.state != "device":
|
|
50
|
+
ret.append(DeviceInfo(serial=d.serial, status=d.state, enabled=False))
|
|
51
|
+
else:
|
|
52
|
+
dev = adb.device(d.serial)
|
|
53
|
+
ret.append(DeviceInfo(serial=d.serial, model=dev.prop.model, name=dev.prop.name))
|
|
54
|
+
return ret
|
|
55
|
+
|
|
56
|
+
@lru_cache
|
|
57
|
+
def get_device_driver(self, serial: str) -> AndroidDriver:
|
|
58
|
+
return AndroidDriver(serial)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class IOSProvider(BaseProvider):
|
|
62
|
+
def list_devices(self) -> list[DeviceInfo]:
|
|
63
|
+
devs = list_devices()
|
|
64
|
+
return [DeviceInfo(serial=d.serial, model="unknown", name="unknown") for d in devs]
|
|
65
|
+
|
|
66
|
+
@lru_cache
|
|
67
|
+
def get_device_driver(self, serial: str) -> BaseDriver:
|
|
68
|
+
return IOSDriver(serial)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MockProvider(BaseProvider):
|
|
72
|
+
def list_devices(self) -> list[DeviceInfo]:
|
|
73
|
+
return [DeviceInfo(serial="mock-serial", model="mock-model", name="mock-name")]
|
|
74
|
+
|
|
75
|
+
def get_device_driver(self, serial: str) -> BaseDriver:
|
|
76
|
+
return MockDriver(serial)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""Created on Fri Mar 01 2024 14:00:10 by codeskyblue
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import io
|
|
8
|
+
from typing import Any, List
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, Response
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from uiautodev import command_proxy
|
|
14
|
+
from uiautodev.command_types import Command, CurrentAppResponse, InstallAppRequest, InstallAppResponse, TapRequest
|
|
15
|
+
from uiautodev.model import DeviceInfo, Node, ShellResponse
|
|
16
|
+
from uiautodev.provider import BaseProvider
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AndroidShellPayload(BaseModel):
|
|
20
|
+
command: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def make_router(provider: BaseProvider) -> APIRouter:
|
|
24
|
+
router = APIRouter()
|
|
25
|
+
|
|
26
|
+
@router.get("/list")
|
|
27
|
+
def _list() -> List[DeviceInfo]:
|
|
28
|
+
"""List of Android devices"""
|
|
29
|
+
try:
|
|
30
|
+
return provider.list_devices()
|
|
31
|
+
except NotImplementedError as e:
|
|
32
|
+
return Response(content="list_devices not implemented", media_type="text/plain", status_code=501)
|
|
33
|
+
except Exception as e:
|
|
34
|
+
return Response(content=str(e), media_type="text/plain", status_code=500)
|
|
35
|
+
|
|
36
|
+
@router.post("/{serial}/shell")
|
|
37
|
+
def android_shell(serial: str, payload: AndroidShellPayload) -> ShellResponse:
|
|
38
|
+
"""Run a shell command on an Android device"""
|
|
39
|
+
try:
|
|
40
|
+
driver = provider.get_device_driver(serial)
|
|
41
|
+
return driver.shell(payload.command)
|
|
42
|
+
except NotImplementedError as e:
|
|
43
|
+
return Response(content="shell not implemented", media_type="text/plain", status_code=501)
|
|
44
|
+
except Exception as e:
|
|
45
|
+
return ShellResponse(output="", error=str(e))
|
|
46
|
+
|
|
47
|
+
@router.get(
|
|
48
|
+
"/{serial}/screenshot/{id}",
|
|
49
|
+
responses={200: {"content": {"image/jpeg": {}}}},
|
|
50
|
+
response_class=Response,
|
|
51
|
+
)
|
|
52
|
+
def _screenshot(serial: str, id: int) -> Response:
|
|
53
|
+
"""Take a screenshot of device"""
|
|
54
|
+
try:
|
|
55
|
+
driver = provider.get_device_driver(serial)
|
|
56
|
+
pil_img = driver.screenshot(id)
|
|
57
|
+
buf = io.BytesIO()
|
|
58
|
+
pil_img.save(buf, format="JPEG")
|
|
59
|
+
image_bytes = buf.getvalue()
|
|
60
|
+
return Response(content=image_bytes, media_type="image/jpeg")
|
|
61
|
+
except Exception as e:
|
|
62
|
+
return Response(content=str(e), media_type="text/plain", status_code=500)
|
|
63
|
+
|
|
64
|
+
@router.get("/{serial}/hierarchy")
|
|
65
|
+
def dump_hierarchy(serial: str) -> Node:
|
|
66
|
+
"""Dump the view hierarchy of an Android device"""
|
|
67
|
+
try:
|
|
68
|
+
driver = provider.get_device_driver(serial)
|
|
69
|
+
xml_data, hierarchy = driver.dump_hierarchy()
|
|
70
|
+
return hierarchy
|
|
71
|
+
except Exception as e:
|
|
72
|
+
return Response(content=str(e), media_type="text/plain", status_code=500)
|
|
73
|
+
|
|
74
|
+
@router.post('/{serial}/command/tap')
|
|
75
|
+
def command_tap(serial: str, params: TapRequest):
|
|
76
|
+
"""Run a command on the device"""
|
|
77
|
+
driver = provider.get_device_driver(serial)
|
|
78
|
+
command_proxy.tap(driver, params)
|
|
79
|
+
return {"status": "ok"}
|
|
80
|
+
|
|
81
|
+
@router.post('/{serial}/command/installApp')
|
|
82
|
+
def install_app(serial: str, params: InstallAppRequest) -> InstallAppResponse:
|
|
83
|
+
"""Install app"""
|
|
84
|
+
driver = provider.get_device_driver(serial)
|
|
85
|
+
return command_proxy.app_install(driver, params)
|
|
86
|
+
|
|
87
|
+
@router.get('/{serial}/command/currentApp')
|
|
88
|
+
def current_app(serial: str) -> CurrentAppResponse:
|
|
89
|
+
"""Get current app"""
|
|
90
|
+
driver = provider.get_device_driver(serial)
|
|
91
|
+
return command_proxy.app_current(driver)
|
|
92
|
+
|
|
93
|
+
@router.post('/{serial}/command/{command}')
|
|
94
|
+
def _command_proxy_other(serial: str, command: Command, params: Any = None):
|
|
95
|
+
"""Run a command on the device"""
|
|
96
|
+
driver = provider.get_device_driver(serial)
|
|
97
|
+
func = command_proxy.COMMANDS[command]
|
|
98
|
+
if params is None:
|
|
99
|
+
response = func(driver)
|
|
100
|
+
else:
|
|
101
|
+
response = func(driver, params)
|
|
102
|
+
return response
|
|
103
|
+
|
|
104
|
+
return router
|
uiautodev/router/xml.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""Created on Tue Mar 05 2024 16:59:19 by codeskyblue
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Form, Response
|
|
8
|
+
from lxml import etree
|
|
9
|
+
from typing_extensions import Annotated
|
|
10
|
+
|
|
11
|
+
router = APIRouter()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@router.post("/check/xpath")
|
|
15
|
+
def check_xpath(xml: Annotated[str, Form()], xpath: Annotated[str, Form()]) -> Response:
|
|
16
|
+
"""Check if the XPath expression is valid"""
|
|
17
|
+
try:
|
|
18
|
+
children = []
|
|
19
|
+
for child in etree.fromstring(xml).xpath(xpath):
|
|
20
|
+
children.append(child)
|
|
21
|
+
if len(children) > 0:
|
|
22
|
+
return Response(content=children[0].tag, media_type="text/plain")
|
|
23
|
+
else:
|
|
24
|
+
return Response(
|
|
25
|
+
content="XPath is valid but not node matches", media_type="text/plain"
|
|
26
|
+
)
|
|
27
|
+
except Exception as e:
|
|
28
|
+
return Response(content=str(e), media_type="text/plain", status_code=400)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>AppInspector Demo</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<h1>App Inspector</h1>
|
|
10
|
+
<div id="message"></div>
|
|
11
|
+
<button id="test">Test</button>
|
|
12
|
+
<script>
|
|
13
|
+
window.onload = function() {
|
|
14
|
+
let message = "<span style='color: green'>App Inspector is installed.</span>"
|
|
15
|
+
if (chrome.runtime === undefined) {
|
|
16
|
+
message = "Please install the App Inspector Chrome Extension to use this feature.";
|
|
17
|
+
}
|
|
18
|
+
document.getElementById('message').innerHTML = `<span>${message}</span>`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
document.getElementById('test').addEventListener('click', function() {
|
|
22
|
+
const extensionId = "fjbboaelofjaabjmlphndicacmapbalm"
|
|
23
|
+
chrome.runtime.sendMessage(extensionId, {url: "/info"}, function(response) {
|
|
24
|
+
console.log(response);
|
|
25
|
+
if (response.error) {
|
|
26
|
+
console.error(response.error)
|
|
27
|
+
} else {
|
|
28
|
+
document.getElementById('message').innerHTML = response.data.description
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
</script>
|
|
33
|
+
</body>
|
|
34
|
+
</html>
|