uiautodev 0.12.0__tar.gz → 0.13.3__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.
Files changed (44) hide show
  1. {uiautodev-0.12.0 → uiautodev-0.13.3}/PKG-INFO +27 -3
  2. {uiautodev-0.12.0 → uiautodev-0.13.3}/README.md +26 -3
  3. {uiautodev-0.12.0 → uiautodev-0.13.3}/pyproject.toml +1 -1
  4. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/__init__.py +1 -1
  5. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/app.py +36 -27
  6. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/cli.py +35 -11
  7. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/command_proxy.py +2 -2
  8. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/common.py +4 -3
  9. uiautodev-0.13.3/uiautodev/driver/android/__init__.py +2 -0
  10. uiautodev-0.12.0/uiautodev/driver/android.py → uiautodev-0.13.3/uiautodev/driver/android/adb_driver.py +29 -72
  11. uiautodev-0.13.3/uiautodev/driver/android/common.py +61 -0
  12. uiautodev-0.13.3/uiautodev/driver/android/u2_driver.py +68 -0
  13. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/driver/base_driver.py +0 -2
  14. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/driver/harmony.py +1 -1
  15. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/provider.py +7 -5
  16. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/router/android.py +3 -3
  17. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/router/device.py +1 -1
  18. uiautodev-0.13.3/uiautodev/router/proxy.py +178 -0
  19. uiautodev-0.12.0/uiautodev/router/proxy.py +0 -57
  20. {uiautodev-0.12.0 → uiautodev-0.13.3}/LICENSE +0 -0
  21. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/__main__.py +0 -0
  22. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/appium_proxy.py +0 -0
  23. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/binaries/scrcpy_server.jar +0 -0
  24. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/case.py +0 -0
  25. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/command_types.py +0 -0
  26. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/driver/appium.py +0 -0
  27. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/driver/ios.py +0 -0
  28. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/driver/mock.py +0 -0
  29. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/driver/testdata/layout.json +0 -0
  30. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/driver/udt/appium-uiautomator2-v5.12.4-light.apk +0 -0
  31. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/driver/udt/udt.py +0 -0
  32. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/exceptions.py +0 -0
  33. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/model.py +0 -0
  34. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/remote/android_input.py +0 -0
  35. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/remote/harmony_mjpeg.py +0 -0
  36. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/remote/keycode.py +0 -0
  37. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/remote/scrcpy.py +0 -0
  38. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/remote/touch_controller.py +0 -0
  39. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/router/xml.py +0 -0
  40. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/static/demo.html +0 -0
  41. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/utils/common.py +0 -0
  42. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/utils/envutils.py +0 -0
  43. {uiautodev-0.12.0 → uiautodev-0.13.3}/uiautodev/utils/exceptions.py +0 -0
  44. {uiautodev-0.12.0 → uiautodev-0.13.3}/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.3
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,29 @@ 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
+
88
+ # Offline mode
89
+
90
+ Start with
91
+
92
+ ```sh
93
+ uiautodev server --offline
94
+
95
+ # Specify server url (optional)
96
+ uiautodev server --offline --server-url https://uiauto.dev
97
+ ```
98
+
99
+ Visit <http://localhost:20242> once, and then disconnecting from the internet will not affect usage.
100
+
101
+ > All frontend resources will be saved to cache/ dir.
102
+
80
103
  # DEVELOP
81
104
 
82
105
  see [DEVELOP.md](DEVELOP.md)
@@ -87,3 +110,4 @@ see [DEVELOP.md](DEVELOP.md)
87
110
 
88
111
  # LICENSE
89
112
  [MIT](LICENSE)
113
+
@@ -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,29 @@ 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
+
52
+ # Offline mode
53
+
54
+ Start with
55
+
56
+ ```sh
57
+ uiautodev server --offline
58
+
59
+ # Specify server url (optional)
60
+ uiautodev server --offline --server-url https://uiauto.dev
61
+ ```
62
+
63
+ Visit <http://localhost:20242> once, and then disconnecting from the internet will not affect usage.
64
+
65
+ > All frontend resources will be saved to cache/ dir.
66
+
44
67
  # DEVELOP
45
68
 
46
69
  see [DEVELOP.md](DEVELOP.md)
@@ -50,4 +73,4 @@ see [DEVELOP.md](DEVELOP.md)
50
73
  - https://docs.tangoapp.dev/scrcpy/video/web-codecs/ H264解码器
51
74
 
52
75
  # LICENSE
53
- [MIT](LICENSE)
76
+ [MIT](LICENSE)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "uiautodev"
3
- version = "0.12.0"
3
+ version = "0.13.3"
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.3"
@@ -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,21 +11,23 @@ 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
18
  from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
19
19
  from pydantic import BaseModel
20
- from rich.logging import RichHandler
21
20
  from starlette.websockets import WebSocketDisconnect
22
21
 
23
22
  from uiautodev import __version__
24
23
  from uiautodev.common import convert_bytes_to_image, get_webpage_url, ocr_image
24
+ from uiautodev.driver.android import ADBAndroidDriver, U2AndroidDriver
25
25
  from uiautodev.model import Node
26
26
  from uiautodev.provider import AndroidProvider, HarmonyProvider, IOSProvider, MockProvider
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 make_reverse_proxy
30
31
  from uiautodev.router.proxy import router as proxy_router
31
32
  from uiautodev.router.xml import router as xml_router
32
33
  from uiautodev.utils.envutils import Environment
@@ -35,16 +36,6 @@ logger = logging.getLogger(__name__)
35
36
 
36
37
  app = FastAPI()
37
38
 
38
-
39
- def enable_logger_to_console():
40
- _logger = logging.getLogger("uiautodev")
41
- _logger.setLevel(logging.DEBUG)
42
- _logger.addHandler(RichHandler(enable_link_path=False))
43
-
44
-
45
- if os.getenv("UIAUTODEV_DEBUG"):
46
- enable_logger_to_console()
47
-
48
39
  app.add_middleware(
49
40
  CORSMiddleware,
50
41
  allow_origins=["*"],
@@ -53,7 +44,12 @@ app.add_middleware(
53
44
  allow_headers=["*"],
54
45
  )
55
46
 
56
- android_router = make_router(AndroidProvider())
47
+ android_default_driver = U2AndroidDriver
48
+ if os.getenv("UIAUTODEV_USE_ADB_DRIVER") in ("1", "true", "True"):
49
+ android_default_driver = ADBAndroidDriver
50
+
51
+ android_router = make_router(AndroidProvider(driver_class=android_default_driver))
52
+ android_adb_router = make_router(AndroidProvider(driver_class=ADBAndroidDriver))
57
53
  ios_router = make_router(IOSProvider())
58
54
  harmony_router = make_router(HarmonyProvider())
59
55
  mock_router = make_router(MockProvider())
@@ -66,25 +62,30 @@ if Environment.UIAUTODEV_MOCK:
66
62
  app.include_router(mock_router, prefix="/api/harmony", tags=["mock"])
67
63
  else:
68
64
  app.include_router(android_router, prefix="/api/android", tags=["android"])
65
+ app.include_router(android_adb_router, prefix="/api/android_adb", tags=["android_adb"])
69
66
  app.include_router(ios_router, prefix="/api/ios", tags=["ios"])
70
67
  app.include_router(harmony_router, prefix="/api/harmony", tags=["harmony"])
71
68
 
72
69
  app.include_router(xml_router, prefix="/api/xml", tags=["xml"])
73
70
  app.include_router(android_device_router, prefix="/api/android", tags=["android"])
74
- app.include_router(proxy_router, prefix="/proxy", tags=["proxy"])
71
+ app.include_router(proxy_router, tags=["proxy"])
75
72
 
76
- @app.get('/api/{platform}/features')
73
+
74
+ @app.get("/api/{platform}/features")
77
75
  def get_features(platform: str) -> Dict[str, bool]:
78
76
  """Get features supported by the specified platform"""
79
77
  features = {}
80
78
  # 获取所有带有指定平台tag的路由
79
+ from starlette.routing import Route
80
+
81
81
  for route in app.routes:
82
- if hasattr(route, 'tags') and platform in route.tags:
83
- if route.path.startswith(f"/api/{platform}/{{serial}}/"):
82
+ _route: Route = route # type: ignore
83
+ if hasattr(_route, "tags") and platform in _route.tags:
84
+ if _route.path.startswith(f"/api/{platform}/{{serial}}/"):
84
85
  # 提取特性名称
85
- parts = route.path.split('/')
86
+ parts = _route.path.split("/")
86
87
  feature_name = parts[-1]
87
- if not feature_name.startswith('{'):
88
+ if not feature_name.startswith("{"):
88
89
  features[feature_name] = True
89
90
  return features
90
91
 
@@ -111,7 +112,7 @@ def info() -> InfoResponse:
111
112
  )
112
113
 
113
114
 
114
- @app.post('/api/ocr_image')
115
+ @app.post("/api/ocr_image")
115
116
  async def _ocr_image(file: UploadFile = File(...)) -> List[Node]:
116
117
  """OCR an image"""
117
118
  image_data = await file.read()
@@ -134,14 +135,19 @@ def demo():
134
135
  return FileResponse(static_dir / "demo.html")
135
136
 
136
137
 
137
- @app.get("/")
138
+ @app.get("/redirect")
138
139
  def index_redirect():
139
- """ redirect to official homepage """
140
+ """redirect to official homepage"""
140
141
  url = get_webpage_url()
141
142
  logger.debug("redirect to %s", url)
142
143
  return RedirectResponse(url)
143
144
 
144
145
 
146
+ @app.get("/api/auth/me")
147
+ def mock_auth_me():
148
+ # 401 {"detail":"Authentication required"}
149
+ return JSONResponse(status_code=401, content={"detail": "Authentication required"})
150
+
145
151
  @app.websocket("/ws/android/scrcpy/{serial}")
146
152
  async def handle_android_ws(websocket: WebSocket, serial: str):
147
153
  """
@@ -169,9 +175,10 @@ def get_harmony_mjpeg_server(serial: str):
169
175
  from hypium import UiDriver
170
176
 
171
177
  from uiautodev.remote.harmony_mjpeg import HarmonyMjpegServer
178
+
172
179
  driver = UiDriver.connect(device_sn=serial)
173
180
  logger.info("create harmony mjpeg server for %s", serial)
174
- logger.info(f'device wake_up_display: {driver.wake_up_display()}')
181
+ logger.info(f"device wake_up_display: {driver.wake_up_display()}")
175
182
  return HarmonyMjpegServer(driver)
176
183
 
177
184
 
@@ -193,7 +200,9 @@ async def unified_harmony_ws(websocket: WebSocket, serial: str):
193
200
  await server.handle_ws(websocket)
194
201
  except ImportError as e:
195
202
  logger.error(f"missing library for harmony: {e}")
196
- await websocket.close(code=1000, reason="missing library, fix by \"pip install uiautodev[harmony]\"")
203
+ await websocket.close(
204
+ code=1000, reason='missing library, fix by "pip install uiautodev[harmony]"'
205
+ )
197
206
  except WebSocketDisconnect:
198
207
  logger.info(f"WebSocket disconnected by client.")
199
208
  except Exception as e:
@@ -203,5 +212,5 @@ async def unified_harmony_ws(websocket: WebSocket, serial: str):
203
212
  logger.info(f"WebSocket closed for serial={serial}")
204
213
 
205
214
 
206
- if __name__ == '__main__':
215
+ if __name__ == "__main__":
207
216
  uvicorn.run("uiautodev.app:app", port=4000, reload=True, use_colors=True)
@@ -20,6 +20,7 @@ import httpx
20
20
  import pydantic
21
21
  import uvicorn
22
22
  from retry import retry
23
+ from rich.logging import RichHandler
23
24
 
24
25
  from uiautodev import __version__, command_proxy
25
26
  from uiautodev.command_types import Command
@@ -38,12 +39,21 @@ HARMONY_PACKAGES = [
38
39
  "https://public.uiauto.devsleep.com/harmony/hypium-5.0.7.200.tar.gz",
39
40
  ]
40
41
 
42
+
43
+ def enable_logger_to_console(level):
44
+ _logger = logging.getLogger("uiautodev")
45
+ _logger.setLevel(level)
46
+ _logger.addHandler(RichHandler(enable_link_path=False))
47
+
48
+
41
49
  @click.group(context_settings=CONTEXT_SETTINGS)
42
50
  @click.option("--verbose", "-v", is_flag=True, default=False, help="verbose mode")
43
51
  def cli(verbose: bool):
44
52
  if verbose:
45
- os.environ['UIAUTODEV_DEBUG'] = '1'
53
+ enable_logger_to_console(level=logging.DEBUG)
46
54
  logger.debug("Verbose mode enabled")
55
+ else:
56
+ enable_logger_to_console(level=logging.INFO)
47
57
 
48
58
 
49
59
  def run_driver_command(provider: BaseProvider, command: Command, params: list[str] = None):
@@ -140,9 +150,11 @@ def pip_install(package: str):
140
150
  @click.option("--port", default=20242, help="port number", show_default=True)
141
151
  @click.option("--host", default="127.0.0.1", help="host", show_default=True)
142
152
  @click.option("--reload", is_flag=True, default=False, help="auto reload, dev only")
143
- @click.option("-f", "--force", is_flag=True, default=False, help="shutdown alrealy runningserver")
153
+ @click.option("-f", "--force", is_flag=True, default=False, help="shutdown already running server")
144
154
  @click.option("-s", "--no-browser", is_flag=True, default=False, help="silent mode, do not open browser")
145
- def server(port: int, host: str, reload: bool, force: bool, no_browser: bool):
155
+ @click.option("--offline", is_flag=True, default=False, help="offline mode, do not use internet")
156
+ @click.option("--server-url", default="https://uiauto.dev", help="uiauto.dev server url", show_default=True)
157
+ def server(port: int, host: str, reload: bool, force: bool, no_browser: bool, offline: bool, server_url: str):
146
158
  click.echo(f"uiautodev version: {__version__}")
147
159
  if force:
148
160
  try:
@@ -154,32 +166,44 @@ def server(port: int, host: str, reload: bool, force: bool, no_browser: bool):
154
166
  if platform.system() == 'Windows':
155
167
  use_color = False
156
168
 
169
+ server_url = server_url.rstrip('/')
170
+ from uiautodev.router import proxy
171
+ proxy.base_url = server_url
172
+
173
+ if offline:
174
+ proxy.cache_dir.mkdir(parents=True, exist_ok=True)
175
+ logger.info("offline mode enabled, cache dir: %s, server url: %s", proxy.cache_dir, proxy.base_url)
176
+
157
177
  if not no_browser:
158
- th = threading.Thread(target=open_browser_when_server_start, args=(f"http://{host}:{port}",))
178
+ th = threading.Thread(target=open_browser_when_server_start, args=(f"http://{host}:{port}", offline))
159
179
  th.daemon = True
160
180
  th.start()
161
181
  uvicorn.run("uiautodev.app:app", host=host, port=port, reload=reload, use_colors=use_color)
162
182
 
183
+ @cli.command(help="shutdown uiauto.dev local server")
184
+ @click.option("--port", default=20242, help="port number", show_default=True)
185
+ def shutdown(port: int):
186
+ try:
187
+ httpx.get(f"http://127.0.0.1:{port}/shutdown", timeout=3)
188
+ except httpx.HTTPError:
189
+ pass
163
190
 
164
- def open_browser_when_server_start(server_url: str):
191
+
192
+ def open_browser_when_server_start(local_server_url: str, offline: bool = False):
165
193
  deadline = time.time() + 10
166
194
  while time.time() < deadline:
167
195
  try:
168
- httpx.get(f"{server_url}/api/info", timeout=1)
196
+ httpx.get(f"{local_server_url}/api/info", timeout=1)
169
197
  break
170
198
  except Exception as e:
171
199
  time.sleep(0.5)
172
200
  import webbrowser
173
- web_url = get_webpage_url()
201
+ web_url = get_webpage_url(local_server_url if offline else None)
174
202
  logger.info("open browser: %s", web_url)
175
203
  webbrowser.open(web_url)
176
204
 
177
205
 
178
206
  def main():
179
- # set logger level to INFO
180
- # logging.basicConfig(level=logging.INFO)
181
- logger.setLevel(logging.INFO)
182
-
183
207
  has_command = False
184
208
  for name in sys.argv[1:]:
185
209
  if not name.startswith("-"):
@@ -13,8 +13,8 @@ from typing import Callable, Dict, List, Optional, Union
13
13
  from pydantic import BaseModel
14
14
 
15
15
  from uiautodev.command_types import AppLaunchRequest, AppTerminateRequest, By, Command, CurrentAppResponse, \
16
- DumpResponse, FindElementRequest, FindElementResponse, InstallAppRequest, InstallAppResponse, SendKeysRequest, TapRequest, \
17
- WindowSizeResponse
16
+ DumpResponse, FindElementRequest, FindElementResponse, InstallAppRequest, InstallAppResponse, SendKeysRequest, \
17
+ TapRequest, WindowSizeResponse
18
18
  from uiautodev.driver.base_driver import BaseDriver
19
19
  from uiautodev.exceptions import ElementNotFoundError
20
20
  from uiautodev.model import AppInfo, Node
@@ -8,7 +8,7 @@
8
8
  import io
9
9
  import locale
10
10
  import logging
11
- from typing import List
11
+ from typing import List, Optional
12
12
 
13
13
  from PIL import Image
14
14
 
@@ -26,8 +26,9 @@ def is_chinese_language() -> bool:
26
26
  return False
27
27
 
28
28
 
29
- def get_webpage_url() -> str:
30
- web_url = "https://uiauto.dev"
29
+ def get_webpage_url(web_url: Optional[str] = None) -> str:
30
+ if not web_url:
31
+ web_url = "https://uiauto.dev"
31
32
  # code will be enabled until uiauto.devsleep.com is ready
32
33
  # if is_chinese_language():
33
34
  # web_url = "https://uiauto.devsleep.com"
@@ -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 ADBAndroidDriver, U2AndroidDriver
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 ADBAndroidDriver, U2AndroidDriver
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,178 @@
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, 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
+ base_url = 'https://uiauto.dev'
18
+
19
+ @router.get("/")
20
+ @router.get("/android/{path:path}")
21
+ @router.get("/ios/{path:path}")
22
+ @router.get("/demo/{path:path}")
23
+ @router.get("/harmony/{path:path}")
24
+ async def proxy_html(request: Request):
25
+ cache = HTTPCache(cache_dir, base_url, key='homepage')
26
+ response = await cache.proxy_request(request, update_cache=True)
27
+ return response
28
+ # update
29
+
30
+ @router.get("/assets/{path:path}")
31
+ @router.get('/favicon.ico')
32
+ async def proxy_assets(request: Request, path: str = ""):
33
+ target_url = f"{base_url}{request.url.path}"
34
+ cache = HTTPCache(cache_dir, target_url)
35
+ return await cache.proxy_request(request)
36
+
37
+
38
+ class HTTPCache:
39
+ def __init__(self, cache_dir: Path, target_url: str, key: Optional[str] = None):
40
+ self.cache_dir = cache_dir
41
+ self.target_url = target_url
42
+ self.key = key or hashlib.md5(target_url.encode()).hexdigest()
43
+ self.file_body = self.cache_dir / 'http' / (self.key + ".body")
44
+ self.file_headers = self.file_body.with_suffix(".headers")
45
+
46
+ async def proxy_request(self, request: Request, update_cache: bool = False):
47
+ response = await self.get_cached_response(request)
48
+ if not response:
49
+ response = await self.proxy_and_save_response(request)
50
+ return response
51
+ if update_cache:
52
+ # async update cache in background
53
+ asyncio.create_task(self.update_cache(request))
54
+ return response
55
+
56
+ async def get_cached_response(self, request: Request):
57
+ if request.method == 'GET' and self.file_body.exists():
58
+ logger.info(f"Cache hit: {self.file_body}")
59
+ headers = {}
60
+ if self.file_headers.exists():
61
+ with self.file_headers.open('rb') as f:
62
+ headers = json.load(f)
63
+ body_fd = self.file_body.open("rb")
64
+ return StreamingResponse(
65
+ content=body_fd,
66
+ status_code=200,
67
+ headers=headers,
68
+ background=BackgroundTask(body_fd.close)
69
+ )
70
+ return None
71
+
72
+ async def update_cache(self, request: Request):
73
+ try:
74
+ await self.proxy_and_save_response(request)
75
+ except Exception as e:
76
+ logger.error("Update cache failed")
77
+
78
+ async def proxy_and_save_response(self, request: Request) -> Response:
79
+ logger.debug(f"Proxying request... {request.url.path}")
80
+ response = await proxy_http(request, self.target_url)
81
+ # save response to cache
82
+ if request.method == "GET" and response.status_code == 200 and self.cache_dir.exists():
83
+ self.file_body.parent.mkdir(parents=True, exist_ok=True)
84
+ with self.file_body.open("wb") as f:
85
+ f.write(response.body)
86
+ with self.file_headers.open("w", encoding="utf-8") as f:
87
+ headers = response.headers
88
+ headers['cache-status'] = 'HIT'
89
+ json.dump(dict(headers), f, indent=2, ensure_ascii=False)
90
+ return response
91
+
92
+
93
+ # WebSocket 转发
94
+ @router.websocket("/proxy/ws/{target_url:path}")
95
+ async def proxy_ws(websocket: WebSocket, target_url: str):
96
+ await websocket.accept()
97
+ logger.info(f"WebSocket target_url: {target_url}")
98
+
99
+ try:
100
+ async with websockets.connect(target_url) as target_ws:
101
+ async def from_client():
102
+ while True:
103
+ msg = await websocket.receive_text()
104
+ await target_ws.send(msg)
105
+
106
+ async def from_server():
107
+ while True:
108
+ msg = await target_ws.recv()
109
+ if isinstance(msg, bytes):
110
+ await websocket.send_bytes(msg)
111
+ elif isinstance(msg, str):
112
+ await websocket.send_text(msg)
113
+ else:
114
+ raise RuntimeError("Unknown message type", msg)
115
+
116
+ await asyncio.gather(from_client(), from_server())
117
+
118
+ except WebSocketDisconnect:
119
+ pass
120
+ except Exception as e:
121
+ logger.error(f"WS Error: {e}")
122
+ await websocket.close()
123
+
124
+ # ref: https://stackoverflow.com/questions/74555102/how-to-forward-fastapi-requests-to-another-server
125
+ def make_reverse_proxy(base_url: str, strip_prefix: str = ""):
126
+ async def _reverse_proxy(request: Request):
127
+ client = httpx.AsyncClient(base_url=base_url)
128
+ client.timeout = httpx.Timeout(30.0, read=300.0)
129
+ path = request.url.path
130
+ if strip_prefix and path.startswith(strip_prefix):
131
+ path = path[len(strip_prefix):]
132
+ target_url = httpx.URL(
133
+ path=path, query=request.url.query.encode("utf-8")
134
+ )
135
+ exclude_headers = [b"host", b"connection", b"accept-encoding"]
136
+ headers = [(k, v) for k, v in request.headers.raw if k not in exclude_headers]
137
+ headers.append((b'accept-encoding', b''))
138
+
139
+ req = client.build_request(
140
+ request.method, target_url, headers=headers, content=request.stream()
141
+ )
142
+ r = await client.send(req, stream=True)#, follow_redirects=True)
143
+
144
+ response_headers = {
145
+ k: v for k, v in r.headers.items()
146
+ if k.lower() not in {"transfer-encoding", "connection", "content-length"}
147
+ }
148
+ async def gen_content():
149
+ async for chunk in r.aiter_bytes(chunk_size=40960):
150
+ yield chunk
151
+
152
+ async def aclose():
153
+ await client.aclose()
154
+
155
+ return StreamingResponse(
156
+ content=gen_content(),
157
+ status_code=r.status_code,
158
+ headers=response_headers,
159
+ background=BackgroundTask(aclose),
160
+ )
161
+
162
+ return _reverse_proxy
163
+
164
+
165
+ async def proxy_http(request: Request, target_url: str):
166
+ logger.info(f"HTTP target_url: {target_url}")
167
+
168
+ async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
169
+ body = await request.body() if request.method in {"POST", "PUT", "PATCH", "DELETE"} else None
170
+ headers = {k: v for k, v in request.headers.items() if k.lower() not in {"host", "x-target-url"}}
171
+ headers['accept-encoding'] = '' # disable gzip
172
+ resp = await client.request(
173
+ request.method,
174
+ target_url,
175
+ content=body,
176
+ headers=headers,
177
+ )
178
+ return Response(content=resp.content, status_code=resp.status_code, headers=dict(resp.headers))
@@ -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