uiautodev 0.12.0__tar.gz → 0.13.1__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.1}/PKG-INFO +24 -3
  2. {uiautodev-0.12.0 → uiautodev-0.13.1}/README.md +23 -3
  3. {uiautodev-0.12.0 → uiautodev-0.13.1}/pyproject.toml +1 -1
  4. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/__init__.py +1 -1
  5. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/app.py +36 -27
  6. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/cli.py +30 -10
  7. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/command_proxy.py +2 -2
  8. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/common.py +4 -3
  9. uiautodev-0.13.1/uiautodev/driver/android/__init__.py +2 -0
  10. uiautodev-0.12.0/uiautodev/driver/android.py → uiautodev-0.13.1/uiautodev/driver/android/adb_driver.py +29 -72
  11. uiautodev-0.13.1/uiautodev/driver/android/common.py +61 -0
  12. uiautodev-0.13.1/uiautodev/driver/android/u2_driver.py +68 -0
  13. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/driver/base_driver.py +0 -2
  14. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/driver/harmony.py +1 -1
  15. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/provider.py +7 -5
  16. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/router/android.py +3 -3
  17. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/router/device.py +1 -1
  18. uiautodev-0.13.1/uiautodev/router/proxy.py +180 -0
  19. uiautodev-0.12.0/uiautodev/router/proxy.py +0 -57
  20. {uiautodev-0.12.0 → uiautodev-0.13.1}/LICENSE +0 -0
  21. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/__main__.py +0 -0
  22. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/appium_proxy.py +0 -0
  23. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/binaries/scrcpy_server.jar +0 -0
  24. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/case.py +0 -0
  25. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/command_types.py +0 -0
  26. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/driver/appium.py +0 -0
  27. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/driver/ios.py +0 -0
  28. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/driver/mock.py +0 -0
  29. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/driver/testdata/layout.json +0 -0
  30. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/driver/udt/appium-uiautomator2-v5.12.4-light.apk +0 -0
  31. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/driver/udt/udt.py +0 -0
  32. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/exceptions.py +0 -0
  33. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/model.py +0 -0
  34. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/remote/android_input.py +0 -0
  35. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/remote/harmony_mjpeg.py +0 -0
  36. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/remote/keycode.py +0 -0
  37. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/remote/scrcpy.py +0 -0
  38. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/remote/touch_controller.py +0 -0
  39. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/router/xml.py +0 -0
  40. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/static/demo.html +0 -0
  41. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/utils/common.py +0 -0
  42. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/utils/envutils.py +0 -0
  43. {uiautodev-0.12.0 → uiautodev-0.13.1}/uiautodev/utils/exceptions.py +0 -0
  44. {uiautodev-0.12.0 → uiautodev-0.13.1}/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.1
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,26 @@ 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
+
96
+ Visit <http://localhost:20242> once, and then disconnecting from the internet will not affect usage.
97
+
98
+ > All frontend resources will be saved to cache/ dir.
99
+
80
100
  # DEVELOP
81
101
 
82
102
  see [DEVELOP.md](DEVELOP.md)
@@ -87,3 +107,4 @@ see [DEVELOP.md](DEVELOP.md)
87
107
 
88
108
  # LICENSE
89
109
  [MIT](LICENSE)
110
+
@@ -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,26 @@ 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
+
60
+ Visit <http://localhost:20242> once, and then disconnecting from the internet will not affect usage.
61
+
62
+ > All frontend resources will be saved to cache/ dir.
63
+
44
64
  # DEVELOP
45
65
 
46
66
  see [DEVELOP.md](DEVELOP.md)
@@ -50,4 +70,4 @@ see [DEVELOP.md](DEVELOP.md)
50
70
  - https://docs.tangoapp.dev/scrcpy/video/web-codecs/ H264解码器
51
71
 
52
72
  # LICENSE
53
- [MIT](LICENSE)
73
+ [MIT](LICENSE)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "uiautodev"
3
- version = "0.12.0"
3
+ version = "0.13.1"
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.1"
@@ -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):
@@ -142,7 +152,8 @@ def pip_install(package: str):
142
152
  @click.option("--reload", is_flag=True, default=False, help="auto reload, dev only")
143
153
  @click.option("-f", "--force", is_flag=True, default=False, help="shutdown alrealy runningserver")
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
+ def server(port: int, host: str, reload: bool, force: bool, no_browser: bool, offline: bool):
146
157
  click.echo(f"uiautodev version: {__version__}")
147
158
  if force:
148
159
  try:
@@ -154,32 +165,41 @@ def server(port: int, host: str, reload: bool, force: bool, no_browser: bool):
154
165
  if platform.system() == 'Windows':
155
166
  use_color = False
156
167
 
168
+ if offline:
169
+ from uiautodev.router.proxy import cache_dir
170
+ cache_dir.mkdir(parents=True, exist_ok=True)
171
+ logger.info("offline mode enabled, cache dir: %s", cache_dir)
172
+
157
173
  if not no_browser:
158
- th = threading.Thread(target=open_browser_when_server_start, args=(f"http://{host}:{port}",))
174
+ th = threading.Thread(target=open_browser_when_server_start, args=(f"http://{host}:{port}", offline))
159
175
  th.daemon = True
160
176
  th.start()
161
177
  uvicorn.run("uiautodev.app:app", host=host, port=port, reload=reload, use_colors=use_color)
162
178
 
179
+ @cli.command(help="shutdown uiauto.dev local server")
180
+ @click.option("--port", default=20242, help="port number", show_default=True)
181
+ def shutdown(port: int):
182
+ try:
183
+ httpx.get(f"http://127.0.0.1:{port}/shutdown", timeout=3)
184
+ except httpx.HTTPError:
185
+ pass
163
186
 
164
- def open_browser_when_server_start(server_url: str):
187
+
188
+ def open_browser_when_server_start(local_server_url: str, offline: bool = False):
165
189
  deadline = time.time() + 10
166
190
  while time.time() < deadline:
167
191
  try:
168
- httpx.get(f"{server_url}/api/info", timeout=1)
192
+ httpx.get(f"{local_server_url}/api/info", timeout=1)
169
193
  break
170
194
  except Exception as e:
171
195
  time.sleep(0.5)
172
196
  import webbrowser
173
- web_url = get_webpage_url()
197
+ web_url = get_webpage_url(local_server_url if offline else None)
174
198
  logger.info("open browser: %s", web_url)
175
199
  webbrowser.open(web_url)
176
200
 
177
201
 
178
202
  def main():
179
- # set logger level to INFO
180
- # logging.basicConfig(level=logging.INFO)
181
- logger.setLevel(logging.INFO)
182
-
183
203
  has_command = False
184
204
  for name in sys.argv[1:]:
185
205
  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,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