uiautodev 0.12.0__tar.gz → 0.13.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.

Files changed (44) hide show
  1. {uiautodev-0.12.0 → uiautodev-0.13.0}/PKG-INFO +27 -3
  2. {uiautodev-0.12.0 → uiautodev-0.13.0}/README.md +26 -2
  3. {uiautodev-0.12.0 → uiautodev-0.13.0}/pyproject.toml +1 -1
  4. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/__init__.py +1 -1
  5. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/app.py +43 -21
  6. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/cli.py +8 -4
  7. uiautodev-0.13.0/uiautodev/driver/android/__init__.py +2 -0
  8. uiautodev-0.12.0/uiautodev/driver/android.py → uiautodev-0.13.0/uiautodev/driver/android/adb_driver.py +29 -72
  9. uiautodev-0.13.0/uiautodev/driver/android/common.py +61 -0
  10. uiautodev-0.13.0/uiautodev/driver/android/u2_driver.py +68 -0
  11. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/driver/base_driver.py +0 -2
  12. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/driver/harmony.py +1 -1
  13. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/provider.py +7 -5
  14. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/router/android.py +3 -3
  15. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/router/device.py +1 -1
  16. uiautodev-0.13.0/uiautodev/router/proxy.py +180 -0
  17. uiautodev-0.12.0/uiautodev/router/proxy.py +0 -57
  18. {uiautodev-0.12.0 → uiautodev-0.13.0}/LICENSE +0 -0
  19. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/__main__.py +0 -0
  20. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/appium_proxy.py +0 -0
  21. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/binaries/scrcpy_server.jar +0 -0
  22. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/case.py +0 -0
  23. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/command_proxy.py +0 -0
  24. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/command_types.py +0 -0
  25. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/common.py +0 -0
  26. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/driver/appium.py +0 -0
  27. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/driver/ios.py +0 -0
  28. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/driver/mock.py +0 -0
  29. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/driver/testdata/layout.json +0 -0
  30. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/driver/udt/appium-uiautomator2-v5.12.4-light.apk +0 -0
  31. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/driver/udt/udt.py +0 -0
  32. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/exceptions.py +0 -0
  33. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/model.py +0 -0
  34. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/remote/android_input.py +0 -0
  35. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/remote/harmony_mjpeg.py +0 -0
  36. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/remote/keycode.py +0 -0
  37. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/remote/scrcpy.py +0 -0
  38. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/remote/touch_controller.py +0 -0
  39. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/router/xml.py +0 -0
  40. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/static/demo.html +0 -0
  41. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/utils/common.py +0 -0
  42. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/utils/envutils.py +0 -0
  43. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/utils/exceptions.py +0 -0
  44. {uiautodev-0.12.0 → uiautodev-0.13.0}/uiautodev/utils/usbmux.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: uiautodev
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: Mobile UI Automation, include UI hierarchy inspector, script recorder
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -64,12 +64,12 @@ Options:
64
64
  -h, --help Show this message and exit.
65
65
 
66
66
  Commands:
67
+ server start uiauto.dev local server [Default]
67
68
  android COMMAND: tap, tapElement, installApp, currentApp,...
68
- appium COMMAND: tap, tapElement, installApp, currentApp,...
69
69
  ios COMMAND: tap, tapElement, installApp, currentApp,...
70
70
  self-update Update uiautodev to latest version
71
- server start uiauto.dev local server [Default]
72
71
  version Print version
72
+ shutdown Shutdown server
73
73
  ```
74
74
 
75
75
  ```bash
@@ -77,6 +77,30 @@ Commands:
77
77
  uiauto.dev
78
78
  ```
79
79
 
80
+ # Environment
81
+
82
+ ```sh
83
+ # Default driver is uiautomator2
84
+ # Set the environment variable below to switch to adb driver
85
+ export UIAUTODEV_USE_ADB_DRIVER=1
86
+
87
+ # Set the env to enable DEBUG log
88
+ export UIAUTODEV_DEBUG=1
89
+ ```
90
+
91
+ # Offline mode
92
+
93
+ Currently, the frontend is deployed on a separate server, so internet connection is required.
94
+ However, some users have limited network environments or restricted internet access. Therefore, an offline cache mode has been added.
95
+ Create a `cache` directory in the directory where uiautodev starts to activate frontend caching.
96
+
97
+ ```sh
98
+ mkdir cache
99
+ uiautodev
100
+ ```
101
+
102
+ Visit <http://localhost:20242> once, and then disconnecting from the internet will not affect usage.
103
+
80
104
  # DEVELOP
81
105
 
82
106
  see [DEVELOP.md](DEVELOP.md)
@@ -28,12 +28,12 @@ Options:
28
28
  -h, --help Show this message and exit.
29
29
 
30
30
  Commands:
31
+ server start uiauto.dev local server [Default]
31
32
  android COMMAND: tap, tapElement, installApp, currentApp,...
32
- appium COMMAND: tap, tapElement, installApp, currentApp,...
33
33
  ios COMMAND: tap, tapElement, installApp, currentApp,...
34
34
  self-update Update uiautodev to latest version
35
- server start uiauto.dev local server [Default]
36
35
  version Print version
36
+ shutdown Shutdown server
37
37
  ```
38
38
 
39
39
  ```bash
@@ -41,6 +41,30 @@ Commands:
41
41
  uiauto.dev
42
42
  ```
43
43
 
44
+ # Environment
45
+
46
+ ```sh
47
+ # Default driver is uiautomator2
48
+ # Set the environment variable below to switch to adb driver
49
+ export UIAUTODEV_USE_ADB_DRIVER=1
50
+
51
+ # Set the env to enable DEBUG log
52
+ export UIAUTODEV_DEBUG=1
53
+ ```
54
+
55
+ # Offline mode
56
+
57
+ Currently, the frontend is deployed on a separate server, so internet connection is required.
58
+ However, some users have limited network environments or restricted internet access. Therefore, an offline cache mode has been added.
59
+ Create a `cache` directory in the directory where uiautodev starts to activate frontend caching.
60
+
61
+ ```sh
62
+ mkdir cache
63
+ uiautodev
64
+ ```
65
+
66
+ Visit <http://localhost:20242> once, and then disconnecting from the internet will not affect usage.
67
+
44
68
  # DEVELOP
45
69
 
46
70
  see [DEVELOP.md](DEVELOP.md)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "uiautodev"
3
- version = "0.12.0"
3
+ version = "0.13.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>"]
@@ -5,4 +5,4 @@
5
5
  """
6
6
 
7
7
  # version is auto managed by poetry
8
- __version__ = "0.12.0"
8
+ __version__ = "0.13.0"
@@ -1,8 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  # -*- coding: utf-8 -*-
3
3
 
4
- """Created on Sun Feb 18 2024 13:48:55 by codeskyblue
5
- """
4
+ """Created on Sun Feb 18 2024 13:48:55 by codeskyblue"""
6
5
 
7
6
  import logging
8
7
  import os
@@ -12,10 +11,11 @@ from pathlib import Path
12
11
  from typing import Dict, List
13
12
 
14
13
  import adbutils
14
+ import httpx
15
15
  import uvicorn
16
- from fastapi import FastAPI, File, UploadFile, WebSocket
16
+ from fastapi import FastAPI, File, Request, Response, UploadFile, WebSocket
17
17
  from fastapi.middleware.cors import CORSMiddleware
18
- from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
18
+ from fastapi.responses import (FileResponse, JSONResponse, RedirectResponse)
19
19
  from pydantic import BaseModel
20
20
  from rich.logging import RichHandler
21
21
  from starlette.websockets import WebSocketDisconnect
@@ -24,10 +24,11 @@ from uiautodev import __version__
24
24
  from uiautodev.common import convert_bytes_to_image, get_webpage_url, ocr_image
25
25
  from uiautodev.model import Node
26
26
  from uiautodev.provider import AndroidProvider, HarmonyProvider, IOSProvider, MockProvider
27
+ from uiautodev.driver.android import ADBAndroidDriver, U2AndroidDriver
27
28
  from uiautodev.remote.scrcpy import ScrcpyServer
28
29
  from uiautodev.router.android import router as android_device_router
29
30
  from uiautodev.router.device import make_router
30
- from uiautodev.router.proxy import router as proxy_router
31
+ from uiautodev.router.proxy import make_reverse_proxy, router as proxy_router
31
32
  from uiautodev.router.xml import router as xml_router
32
33
  from uiautodev.utils.envutils import Environment
33
34
 
@@ -36,14 +37,17 @@ logger = logging.getLogger(__name__)
36
37
  app = FastAPI()
37
38
 
38
39
 
39
- def enable_logger_to_console():
40
+ def enable_logger_to_console(level):
40
41
  _logger = logging.getLogger("uiautodev")
41
- _logger.setLevel(logging.DEBUG)
42
+ _logger.setLevel(level)
42
43
  _logger.addHandler(RichHandler(enable_link_path=False))
43
44
 
44
45
 
45
46
  if os.getenv("UIAUTODEV_DEBUG"):
46
- enable_logger_to_console()
47
+ enable_logger_to_console(level=logging.DEBUG)
48
+ logger.debug("verbose logger enabled")
49
+ else:
50
+ enable_logger_to_console(level=logging.ERROR)
47
51
 
48
52
  app.add_middleware(
49
53
  CORSMiddleware,
@@ -53,7 +57,12 @@ app.add_middleware(
53
57
  allow_headers=["*"],
54
58
  )
55
59
 
56
- android_router = make_router(AndroidProvider())
60
+ android_default_driver = U2AndroidDriver
61
+ if os.getenv("UIAUTODEV_USE_ADB_DRIVER") in ("1", "true", "True"):
62
+ android_default_driver = ADBAndroidDriver
63
+
64
+ android_router = make_router(AndroidProvider(driver_class=android_default_driver))
65
+ android_adb_router = make_router(AndroidProvider(driver_class=ADBAndroidDriver))
57
66
  ios_router = make_router(IOSProvider())
58
67
  harmony_router = make_router(HarmonyProvider())
59
68
  mock_router = make_router(MockProvider())
@@ -66,25 +75,30 @@ if Environment.UIAUTODEV_MOCK:
66
75
  app.include_router(mock_router, prefix="/api/harmony", tags=["mock"])
67
76
  else:
68
77
  app.include_router(android_router, prefix="/api/android", tags=["android"])
78
+ app.include_router(android_adb_router, prefix="/api/android_adb", tags=["android_adb"])
69
79
  app.include_router(ios_router, prefix="/api/ios", tags=["ios"])
70
80
  app.include_router(harmony_router, prefix="/api/harmony", tags=["harmony"])
71
81
 
72
82
  app.include_router(xml_router, prefix="/api/xml", tags=["xml"])
73
83
  app.include_router(android_device_router, prefix="/api/android", tags=["android"])
74
- app.include_router(proxy_router, prefix="/proxy", tags=["proxy"])
84
+ app.include_router(proxy_router, tags=["proxy"])
85
+
75
86
 
76
- @app.get('/api/{platform}/features')
87
+ @app.get("/api/{platform}/features")
77
88
  def get_features(platform: str) -> Dict[str, bool]:
78
89
  """Get features supported by the specified platform"""
79
90
  features = {}
80
91
  # 获取所有带有指定平台tag的路由
92
+ from starlette.routing import Route
93
+
81
94
  for route in app.routes:
82
- if hasattr(route, 'tags') and platform in route.tags:
83
- if route.path.startswith(f"/api/{platform}/{{serial}}/"):
95
+ _route: Route = route # type: ignore
96
+ if hasattr(_route, "tags") and platform in _route.tags:
97
+ if _route.path.startswith(f"/api/{platform}/{{serial}}/"):
84
98
  # 提取特性名称
85
- parts = route.path.split('/')
99
+ parts = _route.path.split("/")
86
100
  feature_name = parts[-1]
87
- if not feature_name.startswith('{'):
101
+ if not feature_name.startswith("{"):
88
102
  features[feature_name] = True
89
103
  return features
90
104
 
@@ -111,7 +125,7 @@ def info() -> InfoResponse:
111
125
  )
112
126
 
113
127
 
114
- @app.post('/api/ocr_image')
128
+ @app.post("/api/ocr_image")
115
129
  async def _ocr_image(file: UploadFile = File(...)) -> List[Node]:
116
130
  """OCR an image"""
117
131
  image_data = await file.read()
@@ -134,14 +148,19 @@ def demo():
134
148
  return FileResponse(static_dir / "demo.html")
135
149
 
136
150
 
137
- @app.get("/")
151
+ @app.get("/redirect")
138
152
  def index_redirect():
139
- """ redirect to official homepage """
153
+ """redirect to official homepage"""
140
154
  url = get_webpage_url()
141
155
  logger.debug("redirect to %s", url)
142
156
  return RedirectResponse(url)
143
157
 
144
158
 
159
+ @app.get("/api/auth/me")
160
+ def mock_auth_me():
161
+ # 401 {"detail":"Authentication required"}
162
+ return JSONResponse(status_code=401, content={"detail": "Authentication required"})
163
+
145
164
  @app.websocket("/ws/android/scrcpy/{serial}")
146
165
  async def handle_android_ws(websocket: WebSocket, serial: str):
147
166
  """
@@ -169,9 +188,10 @@ def get_harmony_mjpeg_server(serial: str):
169
188
  from hypium import UiDriver
170
189
 
171
190
  from uiautodev.remote.harmony_mjpeg import HarmonyMjpegServer
191
+
172
192
  driver = UiDriver.connect(device_sn=serial)
173
193
  logger.info("create harmony mjpeg server for %s", serial)
174
- logger.info(f'device wake_up_display: {driver.wake_up_display()}')
194
+ logger.info(f"device wake_up_display: {driver.wake_up_display()}")
175
195
  return HarmonyMjpegServer(driver)
176
196
 
177
197
 
@@ -193,7 +213,9 @@ async def unified_harmony_ws(websocket: WebSocket, serial: str):
193
213
  await server.handle_ws(websocket)
194
214
  except ImportError as e:
195
215
  logger.error(f"missing library for harmony: {e}")
196
- await websocket.close(code=1000, reason="missing library, fix by \"pip install uiautodev[harmony]\"")
216
+ await websocket.close(
217
+ code=1000, reason='missing library, fix by "pip install uiautodev[harmony]"'
218
+ )
197
219
  except WebSocketDisconnect:
198
220
  logger.info(f"WebSocket disconnected by client.")
199
221
  except Exception as e:
@@ -203,5 +225,5 @@ async def unified_harmony_ws(websocket: WebSocket, serial: str):
203
225
  logger.info(f"WebSocket closed for serial={serial}")
204
226
 
205
227
 
206
- if __name__ == '__main__':
228
+ if __name__ == "__main__":
207
229
  uvicorn.run("uiautodev.app:app", port=4000, reload=True, use_colors=True)
@@ -160,6 +160,14 @@ def server(port: int, host: str, reload: bool, force: bool, no_browser: bool):
160
160
  th.start()
161
161
  uvicorn.run("uiautodev.app:app", host=host, port=port, reload=reload, use_colors=use_color)
162
162
 
163
+ @cli.command(help="shutdown uiauto.dev local server")
164
+ @click.option("--port", default=20242, help="port number", show_default=True)
165
+ def shutdown(port: int):
166
+ try:
167
+ httpx.get(f"http://127.0.0.1:{port}/shutdown", timeout=3)
168
+ except httpx.HTTPError:
169
+ pass
170
+
163
171
 
164
172
  def open_browser_when_server_start(server_url: str):
165
173
  deadline = time.time() + 10
@@ -176,10 +184,6 @@ def open_browser_when_server_start(server_url: str):
176
184
 
177
185
 
178
186
  def main():
179
- # set logger level to INFO
180
- # logging.basicConfig(level=logging.INFO)
181
- logger.setLevel(logging.INFO)
182
-
183
187
  has_command = False
184
188
  for name in sys.argv[1:]:
185
189
  if not name.startswith("-"):
@@ -0,0 +1,2 @@
1
+ from uiautodev.driver.android.adb_driver import ADBAndroidDriver, parse_xml
2
+ from uiautodev.driver.android.u2_driver import U2AndroidDriver
@@ -7,31 +7,24 @@
7
7
  import logging
8
8
  import re
9
9
  import time
10
- from functools import cached_property, partial
11
10
  from typing import Iterator, List, Optional, Tuple
12
- from xml.etree import ElementTree
13
11
 
14
12
  import adbutils
15
- import uiautomator2 as u2
16
13
  from PIL import Image
17
14
 
18
15
  from uiautodev.command_types import CurrentAppResponse
16
+ from uiautodev.driver.android.common import parse_xml
19
17
  from uiautodev.driver.base_driver import BaseDriver
20
- from uiautodev.exceptions import AndroidDriverException, RequestError
18
+ from uiautodev.exceptions import AndroidDriverException
21
19
  from uiautodev.model import AppInfo, Node, Rect, ShellResponse, WindowSize
22
- from uiautodev.utils.common import fetch_through_socket
23
20
 
24
21
  logger = logging.getLogger(__name__)
25
22
 
26
- class AndroidDriver(BaseDriver):
23
+ class ADBAndroidDriver(BaseDriver):
27
24
  def __init__(self, serial: str):
28
25
  super().__init__(serial)
29
26
  self.adb_device = adbutils.device(serial)
30
27
 
31
- @cached_property
32
- def ud(self) -> u2.Device:
33
- return u2.connect_usb(self.serial)
34
-
35
28
  def get_current_activity(self) -> str:
36
29
  ret = self.adb_device.shell2(["dumpsys", "activity", "activities"], rstrip=True, timeout=5)
37
30
  # 使用正则查找包含前台 activity 的行
@@ -44,7 +37,7 @@ class AndroidDriver(BaseDriver):
44
37
  def screenshot(self, id: int) -> Image.Image:
45
38
  if id > 0:
46
39
  raise AndroidDriverException("multi-display is not supported yet for uiautomator2")
47
- return self.ud.screenshot()
40
+ return self.adb_device.screenshot(display_id=id)
48
41
 
49
42
  def shell(self, command: str) -> ShellResponse:
50
43
  try:
@@ -61,8 +54,11 @@ class AndroidDriver(BaseDriver):
61
54
  def dump_hierarchy(self, display_id: Optional[int] = 0) -> Tuple[str, Node]:
62
55
  """returns xml string and hierarchy object"""
63
56
  start = time.time()
64
- xml_data = self._dump_hierarchy_raw()
65
- logger.debug("dump_hierarchy cost: %s", time.time() - start)
57
+ try:
58
+ xml_data = self._dump_hierarchy_raw()
59
+ logger.debug("dump_hierarchy cost: %s", time.time() - start)
60
+ except Exception as e:
61
+ raise AndroidDriverException(f"Failed to dump hierarchy: {str(e)}")
66
62
 
67
63
  wsize = self.adb_device.window_size()
68
64
  logger.debug("window size: %s", wsize)
@@ -78,10 +74,24 @@ class AndroidDriver(BaseDriver):
78
74
  - ERROR: could not get idle state.
79
75
  """
80
76
  try:
81
- return self.ud.dump_hierarchy()
82
- except Exception as e:
83
- raise AndroidDriverException(f"Failed to dump hierarchy: {str(e)}")
77
+ return self.adb_device.dump_hierarchy()
78
+ except adbutils.AdbError as e:
79
+ if "Killed" in str(e):
80
+ self.kill_app_process()
81
+ return self.adb_device.dump_hierarchy()
84
82
 
83
+ def kill_app_process(self):
84
+ logger.debug("Killing app_process")
85
+ pids = []
86
+ for line in self.adb_device.shell("ps -A || ps").splitlines():
87
+ if "app_process" in line:
88
+ fields = line.split()
89
+ if len(fields) >= 2:
90
+ pids.append(int(fields[1]))
91
+ logger.debug(f"App process PID: {fields[1]}")
92
+ for pid in set(pids):
93
+ self.adb_device.shell(f"kill {pid}")
94
+
85
95
  def tap(self, x: int, y: int):
86
96
  self.adb_device.click(x, y)
87
97
 
@@ -179,61 +189,8 @@ class AndroidDriver(BaseDriver):
179
189
  yield from self.adb_device.sync.iter_content(remote_path)
180
190
 
181
191
  def send_keys(self, text: str):
182
- self.ud.send_keys(text)
192
+ self.adb_device.send_keys(text)
183
193
 
184
194
  def clear_text(self):
185
- self.ud.clear_text()
186
-
187
-
188
- def parse_xml(xml_data: str, wsize: WindowSize, display_id: Optional[int] = None) -> Node:
189
- root = ElementTree.fromstring(xml_data)
190
- node = parse_xml_element(root, wsize, display_id)
191
- if node is None:
192
- raise AndroidDriverException("Failed to parse xml")
193
- return node
194
-
195
-
196
- def parse_xml_element(element, wsize: WindowSize, display_id: Optional[int], indexes: List[int] = [0]) -> Optional[Node]:
197
- """
198
- Recursively parse an XML element into a dictionary format.
199
- """
200
- name = element.tag
201
- if name == "node":
202
- name = element.attrib.get("class", "node")
203
- if display_id is not None:
204
- elem_display_id = int(element.attrib.get("display-id", display_id))
205
- if elem_display_id != display_id:
206
- return
207
-
208
- bounds = None
209
- rect = None
210
- # eg: bounds="[883,2222][1008,2265]"
211
- if "bounds" in element.attrib:
212
- bounds = element.attrib["bounds"]
213
- bounds = list(map(int, re.findall(r"\d+", bounds)))
214
- assert len(bounds) == 4
215
- rect = Rect(x=bounds[0], y=bounds[1], width=bounds[2] - bounds[0], height=bounds[3] - bounds[1])
216
- bounds = (
217
- bounds[0] / wsize.width,
218
- bounds[1] / wsize.height,
219
- bounds[2] / wsize.width,
220
- bounds[3] / wsize.height,
221
- )
222
- bounds = map(partial(round, ndigits=4), bounds)
223
-
224
- elem = Node(
225
- key="-".join(map(str, indexes)),
226
- name=name,
227
- bounds=bounds,
228
- rect=rect,
229
- properties={key: element.attrib[key] for key in element.attrib},
230
- children=[],
231
- )
232
-
233
- # Construct xpath for children
234
- for index, child in enumerate(element):
235
- child_node = parse_xml_element(child, wsize, display_id, indexes + [index])
236
- if child_node:
237
- elem.children.append(child_node)
238
-
239
- return elem
195
+ for _ in range(3):
196
+ self.adb_device.shell2("input keyevent DEL --longpress")
@@ -0,0 +1,61 @@
1
+ import re
2
+ from functools import partial
3
+ from typing import List, Optional, Tuple
4
+ from xml.etree import ElementTree
5
+
6
+ from uiautodev.exceptions import AndroidDriverException, RequestError
7
+ from uiautodev.model import AppInfo, Node, Rect, ShellResponse, WindowSize
8
+
9
+
10
+ def parse_xml(xml_data: str, wsize: WindowSize, display_id: Optional[int] = None) -> Node:
11
+ root = ElementTree.fromstring(xml_data)
12
+ node = parse_xml_element(root, wsize, display_id)
13
+ if node is None:
14
+ raise AndroidDriverException("Failed to parse xml")
15
+ return node
16
+
17
+
18
+ def parse_xml_element(element, wsize: WindowSize, display_id: Optional[int], indexes: List[int] = [0]) -> Optional[Node]:
19
+ """
20
+ Recursively parse an XML element into a dictionary format.
21
+ """
22
+ name = element.tag
23
+ if name == "node":
24
+ name = element.attrib.get("class", "node")
25
+ if display_id is not None:
26
+ elem_display_id = int(element.attrib.get("display-id", display_id))
27
+ if elem_display_id != display_id:
28
+ return
29
+
30
+ bounds = None
31
+ rect = None
32
+ # eg: bounds="[883,2222][1008,2265]"
33
+ if "bounds" in element.attrib:
34
+ bounds = element.attrib["bounds"]
35
+ bounds = list(map(int, re.findall(r"\d+", bounds)))
36
+ assert len(bounds) == 4
37
+ rect = Rect(x=bounds[0], y=bounds[1], width=bounds[2] - bounds[0], height=bounds[3] - bounds[1])
38
+ bounds = (
39
+ bounds[0] / wsize.width,
40
+ bounds[1] / wsize.height,
41
+ bounds[2] / wsize.width,
42
+ bounds[3] / wsize.height,
43
+ )
44
+ bounds = map(partial(round, ndigits=4), bounds)
45
+
46
+ elem = Node(
47
+ key="-".join(map(str, indexes)),
48
+ name=name,
49
+ bounds=bounds,
50
+ rect=rect,
51
+ properties={key: element.attrib[key] for key in element.attrib},
52
+ children=[],
53
+ )
54
+
55
+ # Construct xpath for children
56
+ for index, child in enumerate(element):
57
+ child_node = parse_xml_element(child, wsize, display_id, indexes + [index])
58
+ if child_node:
59
+ elem.children.append(child_node)
60
+
61
+ return elem
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """Created on Fri Mar 01 2024 14:19:29 by codeskyblue
5
+ """
6
+
7
+ import logging
8
+ import re
9
+ import time
10
+ from functools import cached_property
11
+ from typing import Optional, Tuple
12
+
13
+ import uiautomator2 as u2
14
+ from PIL import Image
15
+
16
+ from uiautodev.driver.android.adb_driver import ADBAndroidDriver
17
+ from uiautodev.driver.android.common import parse_xml
18
+ from uiautodev.exceptions import AndroidDriverException
19
+ from uiautodev.model import AppInfo, Node, WindowSize
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ class U2AndroidDriver(ADBAndroidDriver):
24
+ def __init__(self, serial: str):
25
+ super().__init__(serial)
26
+
27
+ @cached_property
28
+ def ud(self) -> u2.Device:
29
+ return u2.connect_usb(self.serial)
30
+
31
+ def screenshot(self, id: int) -> Image.Image:
32
+ if id > 0:
33
+ # u2 is not support multi-display yet
34
+ return super().screenshot(id)
35
+ return self.ud.screenshot()
36
+
37
+ def dump_hierarchy(self, display_id: Optional[int] = 0) -> Tuple[str, Node]:
38
+ """returns xml string and hierarchy object"""
39
+ start = time.time()
40
+ xml_data = self._dump_hierarchy_raw()
41
+ logger.debug("dump_hierarchy cost: %s", time.time() - start)
42
+
43
+ wsize = self.adb_device.window_size()
44
+ logger.debug("window size: %s", wsize)
45
+ return xml_data, parse_xml(
46
+ xml_data, WindowSize(width=wsize[0], height=wsize[1]), display_id
47
+ )
48
+
49
+ def _dump_hierarchy_raw(self) -> str:
50
+ """
51
+ uiautomator2 server is conflict with "uiautomator dump" command.
52
+
53
+ uiautomator dump errors:
54
+ - ERROR: could not get idle state.
55
+ """
56
+ try:
57
+ return self.ud.dump_hierarchy()
58
+ except Exception as e:
59
+ raise AndroidDriverException(f"Failed to dump hierarchy: {str(e)}")
60
+
61
+ def tap(self, x: int, y: int):
62
+ self.ud.click(x, y)
63
+
64
+ def send_keys(self, text: str):
65
+ self.ud.send_keys(text)
66
+
67
+ def clear_text(self):
68
+ self.ud.clear_text()
@@ -4,11 +4,9 @@
4
4
  """Created on Fri Mar 01 2024 14:18:30 by codeskyblue
5
5
  """
6
6
  import abc
7
- from io import FileIO
8
7
  from typing import Iterator, List, Tuple
9
8
 
10
9
  from PIL import Image
11
- from pydantic import BaseModel
12
10
 
13
11
  from uiautodev.command_types import CurrentAppResponse
14
12
  from uiautodev.model import AppInfo, Node, ShellResponse, WindowSize
@@ -10,7 +10,7 @@ import tempfile
10
10
  import time
11
11
  import uuid
12
12
  from pathlib import Path
13
- from typing import List, Optional, Tuple, Union, final, Dict
13
+ from typing import Dict, List, Optional, Tuple, Union, final
14
14
 
15
15
  from PIL import Image
16
16
 
@@ -7,10 +7,11 @@ from __future__ import annotations
7
7
 
8
8
  import abc
9
9
  from functools import lru_cache
10
+ from typing import Type
10
11
 
11
12
  import adbutils
12
13
 
13
- from uiautodev.driver.android import AndroidDriver
14
+ from uiautodev.driver.android import U2AndroidDriver, ADBAndroidDriver
14
15
  from uiautodev.driver.base_driver import BaseDriver
15
16
  from uiautodev.driver.harmony import HDC, HarmonyDriver
16
17
  from uiautodev.driver.ios import IOSDriver
@@ -40,8 +41,8 @@ class BaseProvider(abc.ABC):
40
41
 
41
42
 
42
43
  class AndroidProvider(BaseProvider):
43
- def __init__(self):
44
- pass
44
+ def __init__(self, driver_class: Type[BaseDriver] = U2AndroidDriver):
45
+ self.driver_class = driver_class
45
46
 
46
47
  def list_devices(self) -> list[DeviceInfo]:
47
48
  adb = adbutils.AdbClient()
@@ -61,8 +62,9 @@ class AndroidProvider(BaseProvider):
61
62
  return ret
62
63
 
63
64
  @lru_cache
64
- def get_device_driver(self, serial: str) -> AndroidDriver:
65
- return AndroidDriver(serial)
65
+ def get_device_driver(self, serial: str) -> BaseDriver:
66
+ return self.driver_class(serial)
67
+
66
68
 
67
69
 
68
70
  class IOSProvider(BaseProvider):
@@ -6,7 +6,7 @@ from typing import Dict, Optional
6
6
  from fastapi import APIRouter, Request, Response
7
7
  from pydantic import BaseModel
8
8
 
9
- from uiautodev.driver.android import AndroidDriver
9
+ from uiautodev.driver.android import U2AndroidDriver, ADBAndroidDriver
10
10
  from uiautodev.model import ShellResponse
11
11
 
12
12
  logger = logging.getLogger(__name__)
@@ -21,7 +21,7 @@ class AndroidShellPayload(BaseModel):
21
21
  def shell(serial: str, payload: AndroidShellPayload) -> ShellResponse:
22
22
  """Run a shell command on an Android device"""
23
23
  try:
24
- driver = AndroidDriver(serial)
24
+ driver = ADBAndroidDriver(serial)
25
25
  return driver.shell(payload.command)
26
26
  except NotImplementedError as e:
27
27
  return Response(content="shell not implemented", media_type="text/plain", status_code=501)
@@ -34,7 +34,7 @@ def shell(serial: str, payload: AndroidShellPayload) -> ShellResponse:
34
34
  async def get_current_activity(serial: str) -> Response:
35
35
  """Get the current activity of the Android device"""
36
36
  try:
37
- driver = AndroidDriver(serial)
37
+ driver = ADBAndroidDriver(serial)
38
38
  activity = driver.get_current_activity()
39
39
  return Response(content=activity, media_type="text/plain")
40
40
  except Exception as e:
@@ -53,7 +53,7 @@ def make_router(provider: BaseProvider) -> APIRouter:
53
53
  return Response(content=str(e), media_type="text/plain", status_code=500)
54
54
 
55
55
  @router.get("/{serial}/hierarchy")
56
- def dump_hierarchy(serial: str, format: str = "json") -> Node:
56
+ def dump_hierarchy(serial: str, format: str = "json"):
57
57
  """Dump the view hierarchy of an Android device"""
58
58
  try:
59
59
  driver = provider.get_device_driver(serial)
@@ -0,0 +1,180 @@
1
+ import asyncio
2
+ import hashlib
3
+ import json
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import httpx
9
+ import websockets
10
+ from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, WebSocket, WebSocketDisconnect
11
+ from fastapi.responses import Response, StreamingResponse
12
+ from starlette.background import BackgroundTask
13
+
14
+ logger = logging.getLogger(__name__)
15
+ router = APIRouter()
16
+ cache_dir = Path("./cache")
17
+
18
+ # HTTP 转发
19
+ @router.api_route("/proxy/http/{target_url:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
20
+ async def proxy_http(request: Request, target_url: str):
21
+ logger.info(f"HTTP target_url: {target_url}")
22
+
23
+ async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
24
+ body = await request.body() if request.method in {"POST", "PUT", "PATCH", "DELETE"} else None
25
+ headers = {k: v for k, v in request.headers.items() if k.lower() not in {"host", "x-target-url"}}
26
+ headers['accept-encoding'] = '' # disable gzip
27
+ resp = await client.request(
28
+ request.method,
29
+ target_url,
30
+ content=body,
31
+ headers=headers,
32
+ )
33
+ return Response(content=resp.content, status_code=resp.status_code, headers=dict(resp.headers))
34
+
35
+
36
+ @router.get("/")
37
+ @router.get("/android/{path:path}")
38
+ @router.get("/ios/{path:path}")
39
+ @router.get("/demo/{path:path}")
40
+ @router.get("/harmony/{path:path}")
41
+ async def proxy_html(request: Request):
42
+ target_url = "https://uiauto.dev/"
43
+ cache = HTTPCache(cache_dir, target_url, key='homepage')
44
+ response = await cache.proxy_request(request, update_cache=True)
45
+ return response
46
+ # update
47
+
48
+ @router.get("/assets/{path:path}")
49
+ @router.get('/favicon.ico')
50
+ async def proxy_assets(request: Request, path: str = ""):
51
+ target_url = f"https://uiauto.dev{request.url.path}"
52
+ cache = HTTPCache(cache_dir, target_url)
53
+ return await cache.proxy_request(request)
54
+
55
+
56
+ class HTTPCache:
57
+ def __init__(self, cache_dir: Path, target_url: str, key: Optional[str] = None):
58
+ self.cache_dir = cache_dir
59
+ self.target_url = target_url
60
+ self.key = key or hashlib.md5(target_url.encode()).hexdigest()
61
+ self.file_body = self.cache_dir / 'http' / (self.key + ".body")
62
+ self.file_headers = self.file_body.with_suffix(".headers")
63
+
64
+ async def proxy_request(self, request: Request, update_cache: bool = False):
65
+ response = await self.get_cached_response(request)
66
+ if not response:
67
+ response = await self.proxy_and_save_response(request)
68
+ return response
69
+ if update_cache:
70
+ # async update cache in background
71
+ asyncio.create_task(self.update_cache(request))
72
+ return response
73
+
74
+ async def get_cached_response(self, request: Request):
75
+ if request.method == 'GET' and self.file_body.exists():
76
+ logger.info(f"Cache hit: {self.file_body}")
77
+ headers = {}
78
+ if self.file_headers.exists():
79
+ with self.file_headers.open('rb') as f:
80
+ headers = json.load(f)
81
+ body_fd = self.file_body.open("rb")
82
+ return StreamingResponse(
83
+ content=body_fd,
84
+ status_code=200,
85
+ headers=headers,
86
+ background=BackgroundTask(body_fd.close)
87
+ )
88
+ return None
89
+
90
+ async def update_cache(self, request: Request):
91
+ try:
92
+ await self.proxy_and_save_response(request)
93
+ except Exception as e:
94
+ logger.error("Update cache failed")
95
+
96
+ async def proxy_and_save_response(self, request: Request) -> Response:
97
+ logger.debug(f"Proxying request... {request.url.path}")
98
+ response = await proxy_http(request, self.target_url)
99
+ # save response to cache
100
+ if request.method == "GET" and response.status_code == 200 and self.cache_dir.exists():
101
+ self.file_body.parent.mkdir(parents=True, exist_ok=True)
102
+ with self.file_body.open("wb") as f:
103
+ f.write(response.body)
104
+ with self.file_headers.open("w", encoding="utf-8") as f:
105
+ headers = response.headers
106
+ headers['cache-status'] = 'HIT'
107
+ json.dump(dict(headers), f, indent=2, ensure_ascii=False)
108
+ return response
109
+
110
+
111
+ # WebSocket 转发
112
+ @router.websocket("/proxy/ws/{target_url:path}")
113
+ async def proxy_ws(websocket: WebSocket, target_url: str):
114
+ await websocket.accept()
115
+ logger.info(f"WebSocket target_url: {target_url}")
116
+
117
+ try:
118
+ async with websockets.connect(target_url) as target_ws:
119
+ async def from_client():
120
+ while True:
121
+ msg = await websocket.receive_text()
122
+ await target_ws.send(msg)
123
+
124
+ async def from_server():
125
+ while True:
126
+ msg = await target_ws.recv()
127
+ if isinstance(msg, bytes):
128
+ await websocket.send_bytes(msg)
129
+ elif isinstance(msg, str):
130
+ await websocket.send_text(msg)
131
+ else:
132
+ raise RuntimeError("Unknown message type", msg)
133
+
134
+ await asyncio.gather(from_client(), from_server())
135
+
136
+ except WebSocketDisconnect:
137
+ pass
138
+ except Exception as e:
139
+ logger.error(f"WS Error: {e}")
140
+ await websocket.close()
141
+
142
+ # ref: https://stackoverflow.com/questions/74555102/how-to-forward-fastapi-requests-to-another-server
143
+ def make_reverse_proxy(base_url: str, strip_prefix: str = ""):
144
+ async def _reverse_proxy(request: Request):
145
+ client = httpx.AsyncClient(base_url=base_url)
146
+ client.timeout = httpx.Timeout(30.0, read=300.0)
147
+ path = request.url.path
148
+ if strip_prefix and path.startswith(strip_prefix):
149
+ path = path[len(strip_prefix):]
150
+ target_url = httpx.URL(
151
+ path=path, query=request.url.query.encode("utf-8")
152
+ )
153
+ exclude_headers = [b"host", b"connection", b"accept-encoding"]
154
+ headers = [(k, v) for k, v in request.headers.raw if k not in exclude_headers]
155
+ headers.append((b'accept-encoding', b''))
156
+
157
+ req = client.build_request(
158
+ request.method, target_url, headers=headers, content=request.stream()
159
+ )
160
+ r = await client.send(req, stream=True)#, follow_redirects=True)
161
+
162
+ response_headers = {
163
+ k: v for k, v in r.headers.items()
164
+ if k.lower() not in {"transfer-encoding", "connection", "content-length"}
165
+ }
166
+ async def gen_content():
167
+ async for chunk in r.aiter_bytes(chunk_size=40960):
168
+ yield chunk
169
+
170
+ async def aclose():
171
+ await client.aclose()
172
+
173
+ return StreamingResponse(
174
+ content=gen_content(),
175
+ status_code=r.status_code,
176
+ headers=response_headers,
177
+ background=BackgroundTask(aclose),
178
+ )
179
+
180
+ return _reverse_proxy
@@ -1,57 +0,0 @@
1
- import asyncio
2
- import logging
3
-
4
- import httpx
5
- import websockets
6
- from fastapi import APIRouter, HTTPException, Request, WebSocket, WebSocketDisconnect
7
- from fastapi.responses import Response
8
-
9
- logger = logging.getLogger(__name__)
10
- router = APIRouter()
11
-
12
-
13
- # HTTP 转发
14
- @router.api_route("/http/{target_url:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
15
- async def proxy_http(request: Request, target_url: str):
16
- logger.info(f"HTTP target_url: {target_url}")
17
-
18
- async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
19
- body = await request.body()
20
- resp = await client.request(
21
- request.method,
22
- target_url,
23
- content=body,
24
- headers={k: v for k, v in request.headers.items() if k.lower() != "host" and k.lower() != "x-target-url"}
25
- )
26
- return Response(content=resp.content, status_code=resp.status_code, headers=dict(resp.headers))
27
-
28
- # WebSocket 转发
29
- @router.websocket("/ws/{target_url:path}")
30
- async def proxy_ws(websocket: WebSocket, target_url: str):
31
- await websocket.accept()
32
- logger.info(f"WebSocket target_url: {target_url}")
33
-
34
- try:
35
- async with websockets.connect(target_url) as target_ws:
36
- async def from_client():
37
- while True:
38
- msg = await websocket.receive_text()
39
- await target_ws.send(msg)
40
-
41
- async def from_server():
42
- while True:
43
- msg = await target_ws.recv()
44
- if isinstance(msg, bytes):
45
- await websocket.send_bytes(msg)
46
- elif isinstance(msg, str):
47
- await websocket.send_text(msg)
48
- else:
49
- raise RuntimeError("Unknown message type", msg)
50
-
51
- await asyncio.gather(from_client(), from_server())
52
-
53
- except WebSocketDisconnect:
54
- pass
55
- except Exception as e:
56
- logger.error(f"WS Error: {e}")
57
- await websocket.close()
File without changes
File without changes