├── chrome_plugin ├── popup.js ├── options.js ├── client.js ├── options.html ├── image │ ├── get_started128.png │ ├── get_started16.png │ ├── get_started32.png │ └── get_started48.png ├── popup.css ├── popup.html ├── d.js ├── manifest.json └── background.js ├── poetry.toml ├── codecov.yml ├── uiautodev ├── binaries │ ├── scrcpy-server-v2.7.jar │ └── scrcpy-server-v3.3.3.jar ├── driver │ ├── udt │ │ ├── appium-uiautomator2-v5.12.4-light.apk │ │ └── udt.py │ ├── android │ │ ├── __init__.py │ │ ├── u2_driver.py │ │ ├── common.py │ │ └── adb_driver.py │ ├── mock.py │ ├── base_driver.py │ ├── ios.py │ ├── appium.py │ └── harmony.py ├── __init__.py ├── __main__.py ├── utils │ ├── envutils.py │ ├── exceptions.py │ ├── common.py │ └── usbmux.py ├── exceptions.py ├── router │ ├── xml.py │ ├── android.py │ ├── device.py │ └── proxy.py ├── model.py ├── static │ └── demo.html ├── common.py ├── appium_proxy.py ├── command_types.py ├── remote │ ├── android_input.py │ ├── scrcpy3.py │ ├── pipe.py │ ├── touch_controller.py │ ├── scrcpy.py │ ├── harmony_mjpeg.py │ └── keycode.py ├── provider.py ├── case.py ├── command_proxy.py ├── cli.py └── app.py ├── pytest.ini ├── runtest.sh ├── Makefile ├── tests ├── test_pydantic.py ├── test_api.py ├── test_android.py └── test_touch_controller.py ├── e2etests ├── test_scrcpy.py └── test_harmony_driver.py ├── .vscode └── launch.json ├── .coveragerc ├── .github └── workflows │ ├── release.yml │ └── main.yml ├── LICENSE ├── pyproject.toml ├── DEVELOP.md ├── README.md ├── .gitignore └── examples └── harmony-video.html /chrome_plugin/popup.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /chrome_plugin/options.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /poetry.toml: -------------------------------------------------------------------------------- 1 | [virtualenvs] 2 | in-project = true 3 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | token: d4ab8f2c-0898-4b61-91c0-c0008863c201 -------------------------------------------------------------------------------- /chrome_plugin/client.js: -------------------------------------------------------------------------------- 1 | var Client = (function() { 2 | return {} 3 | })(); -------------------------------------------------------------------------------- /chrome_plugin/options.html: -------------------------------------------------------------------------------- 1 |

Options demo page

2 | -------------------------------------------------------------------------------- /chrome_plugin/image/get_started128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeskyblue/uiautodev/HEAD/chrome_plugin/image/get_started128.png -------------------------------------------------------------------------------- /chrome_plugin/image/get_started16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeskyblue/uiautodev/HEAD/chrome_plugin/image/get_started16.png -------------------------------------------------------------------------------- /chrome_plugin/image/get_started32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeskyblue/uiautodev/HEAD/chrome_plugin/image/get_started32.png -------------------------------------------------------------------------------- /chrome_plugin/image/get_started48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeskyblue/uiautodev/HEAD/chrome_plugin/image/get_started48.png -------------------------------------------------------------------------------- /uiautodev/binaries/scrcpy-server-v2.7.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeskyblue/uiautodev/HEAD/uiautodev/binaries/scrcpy-server-v2.7.jar -------------------------------------------------------------------------------- /uiautodev/binaries/scrcpy-server-v3.3.3.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeskyblue/uiautodev/HEAD/uiautodev/binaries/scrcpy-server-v3.3.3.jar -------------------------------------------------------------------------------- /chrome_plugin/popup.css: -------------------------------------------------------------------------------- 1 | #wrapper { 2 | width: 200px; 3 | font-size: 20px; 4 | font-family: 'Open Sans', sans-serif; 5 | margin: 10px 10px; 6 | } -------------------------------------------------------------------------------- /uiautodev/driver/udt/appium-uiautomator2-v5.12.4-light.apk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeskyblue/uiautodev/HEAD/uiautodev/driver/udt/appium-uiautomator2-v5.12.4-light.apk -------------------------------------------------------------------------------- /uiautodev/driver/android/__init__.py: -------------------------------------------------------------------------------- 1 | from uiautodev.driver.android.adb_driver import ADBAndroidDriver, parse_xml 2 | from uiautodev.driver.android.u2_driver import U2AndroidDriver 3 | -------------------------------------------------------------------------------- /uiautodev/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Mon Mar 04 2024 14:28:53 by codeskyblue 5 | """ 6 | 7 | # version is auto managed by poetry 8 | __version__ = "0.0.0" -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -v 3 | testpaths = 4 | tests 5 | log_cli = true 6 | log_cli_level = DEBUG 7 | log_cli_format = %(asctime)s [%(levelname)s] %(name)s: %(message)s 8 | log_cli_date_format = %Y-%m-%d %H:%M:%S -------------------------------------------------------------------------------- /uiautodev/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Sun Feb 18 2024 14:20:15 by codeskyblue 5 | """ 6 | 7 | from uiautodev.cli import main 8 | 9 | if __name__ == "__main__": 10 | main() -------------------------------------------------------------------------------- /runtest.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | 4 | set -e 5 | 6 | poetry run uiauto.dev android --help 7 | poetry run uiauto.dev appium --help 8 | poetry run uiauto.dev ios --help 9 | poetry run uiauto.dev version 10 | poetry run uiauto.dev server --help 11 | -------------------------------------------------------------------------------- /uiautodev/utils/envutils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def is_enabled(name: str) -> bool: 5 | return os.getenv(name, "false").lower() in ("true", "1", "on", "yes", "y") 6 | 7 | 8 | class Environment: 9 | UIAUTODEV_MOCK = is_enabled("UIAUTODEV_MOCK") 10 | -------------------------------------------------------------------------------- /chrome_plugin/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | Goto App Inspector 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | format: 2 | poetry run isort . -m HANGING_INDENT -l 120 3 | 4 | test: 5 | poetry run pytest -v tests 6 | 7 | cov: 8 | poetry run pytest --cov=. --cov-report xml --cov-report term 9 | 10 | dev: format 11 | poetry run uiauto.dev -v server --reload --port 20242 12 | 13 | mock: 14 | poetry run uiauto.dev server --mock --port 20242 --reload 15 | 16 | build: 17 | rm -fr dist/ && poetry build 18 | -------------------------------------------------------------------------------- /tests/test_pydantic.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Sun Apr 14 2024 22:32:43 by codeskyblue 5 | """ 6 | 7 | from pydantic import BaseModel 8 | 9 | 10 | class MyBool(BaseModel): 11 | value: bool 12 | 13 | 14 | def test_evalute_bool(): 15 | b = MyBool.model_validate({"value": "true"}) 16 | assert b.value == True 17 | 18 | # b.model_ 19 | # dump_result = b.model_dump() 20 | # print(dump_result) -------------------------------------------------------------------------------- /e2etests/test_scrcpy.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import adbutils 4 | import pytest 5 | 6 | from uiautodev.remote.scrcpy import ScrcpyServer 7 | 8 | logger = logging.getLogger(__name__) 9 | from adbutils._device import AdbDevice 10 | 11 | 12 | @pytest.fixture 13 | def device() -> AdbDevice: 14 | dev = adbutils.adb.device() 15 | return dev 16 | 17 | 18 | def test_scrcpy_video(device: AdbDevice): 19 | server = ScrcpyServer(device) 20 | assert server.resolution_width > 0 21 | assert server.resolution_height > 0 22 | server.close() -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python Debugger: Current File with Arguments", 9 | "type": "debugpy", 10 | "request": "launch", 11 | "program": "${file}", 12 | "console": "integratedTerminal", 13 | "args": "${command:pickArgs}" 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [tool.coverage.run] 2 | branch = true 3 | 4 | [tool.coverage.report] 5 | # Regexes for lines to exclude from consideration 6 | exclude_also = [ 7 | # Don't complain about missing debug-only code: 8 | "def __repr__", 9 | "if self\\.debug", 10 | 11 | # Don't complain if tests don't hit defensive assertion code: 12 | "raise AssertionError", 13 | "raise NotImplementedError", 14 | 15 | # Don't complain if non-runnable code isn't run: 16 | "if 0:", 17 | "if __name__ == .__main__.:", 18 | 19 | # Don't complain about abstract methods, they aren't run: 20 | "@(abc\\.)?abstractmethod", 21 | ] 22 | ignore_errors = true 23 | omit = 24 | "tests/*", 25 | "docs/*" 26 | -------------------------------------------------------------------------------- /uiautodev/exceptions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Tue Mar 05 2024 11:16:29 by codeskyblue 5 | """ 6 | 7 | class UiautoException(Exception): 8 | pass 9 | 10 | 11 | class DriverException(UiautoException): 12 | """Base class for all driver-related exceptions.""" 13 | pass 14 | 15 | class IOSDriverException(DriverException): ... 16 | class AndroidDriverException(DriverException): ... 17 | class HarmonyDriverException(DriverException): ... 18 | class AppiumDriverException(DriverException): ... 19 | 20 | 21 | class MethodError(UiautoException): 22 | pass 23 | 24 | 25 | class ElementNotFoundError(MethodError): ... 26 | class RequestError(UiautoException): ... -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Set up Python 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: 3.9 19 | 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install poetry 24 | 25 | - name: Build 26 | run: | 27 | poetry self add "poetry-dynamic-versioning[plugin]" 28 | rm -fr dist/ && poetry build 29 | 30 | - name: Publish distribution 📦 to PyPI 31 | uses: pypa/gh-action-pypi-publish@release/v1 32 | with: 33 | skip-existing: true 34 | password: ${{ secrets.PYPI_TOKEN }} -------------------------------------------------------------------------------- /uiautodev/utils/exceptions.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Tue Mar 05 2024 10:18:09 by codeskyblue 5 | 6 | Copy from https://github.com/doronz88/pymobiledevice3 7 | """ 8 | 9 | 10 | from uiautodev.exceptions import IOSDriverException 11 | 12 | 13 | class NotPairedError(IOSDriverException): 14 | pass 15 | 16 | 17 | 18 | class MuxException(IOSDriverException): 19 | pass 20 | 21 | 22 | class MuxVersionError(MuxException): 23 | pass 24 | 25 | 26 | class BadCommandError(MuxException): 27 | pass 28 | 29 | 30 | class BadDevError(MuxException): 31 | pass 32 | 33 | 34 | class ConnectionFailedError(MuxException): 35 | pass 36 | 37 | 38 | class ConnectionFailedToUsbmuxdError(ConnectionFailedError): 39 | pass 40 | 41 | 42 | class ArgumentError(IOSDriverException): 43 | pass -------------------------------------------------------------------------------- /uiautodev/router/xml.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Tue Mar 05 2024 16:59:19 by codeskyblue 5 | """ 6 | 7 | from fastapi import APIRouter, Form, Response 8 | from lxml import etree 9 | from typing_extensions import Annotated 10 | 11 | router = APIRouter() 12 | 13 | 14 | @router.post("/check/xpath") 15 | def check_xpath(xml: Annotated[str, Form()], xpath: Annotated[str, Form()]) -> Response: 16 | """Check if the XPath expression is valid""" 17 | try: 18 | children = [] 19 | for child in etree.fromstring(xml).xpath(xpath): 20 | children.append(child) 21 | if len(children) > 0: 22 | return Response(content=children[0].tag, media_type="text/plain") 23 | else: 24 | return Response( 25 | content="XPath is valid but not node matches", media_type="text/plain" 26 | ) 27 | except Exception as e: 28 | return Response(content=str(e), media_type="text/plain", status_code=400) 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 codeskyblue 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /chrome_plugin/d.js: -------------------------------------------------------------------------------- 1 | var D = (function createD() { 2 | 3 | let _verbose = false; // FIXME: logs (beta) 4 | 5 | function setVerbose(verbose) { 6 | _verbose = verbose; 7 | } 8 | 9 | function func(name, args) { 10 | if (_verbose) { 11 | let params = ""; 12 | const len = args.length; 13 | for (let i = 0; i < len; i++) { 14 | params += args[i]; 15 | if (i < len - 1) { 16 | params += ", "; 17 | } 18 | } 19 | console.log(name + "(" + params + ")"); 20 | } 21 | } 22 | 23 | function print(text) { 24 | if (_verbose) { 25 | console.log(text); 26 | } 27 | } 28 | 29 | function error(text) { 30 | console.error(text); 31 | } 32 | 33 | return { 34 | func: function() { 35 | const name = arguments.callee.caller.name; 36 | return func(name, arguments) 37 | }, 38 | setVerbose, 39 | getVerbose: function() { 40 | return _verbose; 41 | }, 42 | print, 43 | error 44 | }; 45 | })(); -------------------------------------------------------------------------------- /uiautodev/model.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Sun Feb 18 2024 11:12:33 by codeskyblue 5 | """ 6 | from __future__ import annotations 7 | 8 | import typing 9 | from typing import Dict, List, Optional, Tuple, Union 10 | 11 | from pydantic import BaseModel 12 | 13 | 14 | class DeviceInfo(BaseModel): 15 | serial: str 16 | model: str = "" 17 | product: str = "" 18 | name: str = "" 19 | status: str = "" 20 | enabled: bool = True 21 | 22 | 23 | class ShellResponse(BaseModel): 24 | output: str 25 | error: Optional[str] = "" 26 | 27 | 28 | class Rect(BaseModel): 29 | x: int 30 | y: int 31 | width: int 32 | height: int 33 | 34 | 35 | class Node(BaseModel): 36 | key: str 37 | name: str # can be seen as description 38 | bounds: Optional[Tuple[float, float, float, float]] = None 39 | rect: Optional[Rect] = None 40 | properties: Dict[str, Union[str, bool]] = {} 41 | children: List[Node] = [] 42 | 43 | 44 | class OCRNode(Node): 45 | confidence: float 46 | 47 | 48 | class WindowSize(typing.NamedTuple): 49 | width: int 50 | height: int 51 | 52 | 53 | class AppInfo(BaseModel): 54 | packageName: str 55 | versionName: Optional[str] = None # Allow None values 56 | versionCode: Optional[int] = None 57 | -------------------------------------------------------------------------------- /chrome_plugin/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "uiauto.dev Extension", 3 | "short_name": "uiauto.dev", 4 | "author": "codeskyblue", 5 | "description": "uiauto.dev Extension helps the website app-inspector.devsleep.com communicate with the client.", 6 | "version": "0.1.0", 7 | "manifest_version": 3, 8 | "background": { 9 | "service_worker": "background.js", 10 | "type": "module" 11 | }, 12 | "content_scripts": [ 13 | { 14 | "matches": ["*://app-inspector.devsleep.com/*", "*://localhost/*"], 15 | "js": ["d.js"] 16 | } 17 | ], 18 | "options_page": "options.html", 19 | "permissions": ["storage", "activeTab", "scripting"], 20 | "externally_connectable": { 21 | "matches": ["*://app-inspector.devsleep.com/*", "*://localhost/*"] 22 | }, 23 | "action": { 24 | "default_popup": "popup.html", 25 | "default_icon": { 26 | "16": "image/get_started16.png", 27 | "32": "image/get_started32.png", 28 | "48": "image/get_started48.png", 29 | "128": "image/get_started128.png" 30 | } 31 | }, 32 | "icons": { 33 | "16": "image/get_started16.png", 34 | "32": "image/get_started32.png", 35 | "48": "image/get_started48.png", 36 | "128": "image/get_started128.png" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Mon Mar 04 2024 14:00:52 by codeskyblue 5 | """ 6 | 7 | from fastapi.testclient import TestClient 8 | 9 | from uiautodev.app import app 10 | 11 | client = TestClient(app) 12 | 13 | def test_api_info(): 14 | response = client.get("/api/info") 15 | assert response.status_code == 200 16 | data = response.json() 17 | for k in ['version', 'description', 'platform', 'code_language', 'cwd']: 18 | assert k in data 19 | 20 | 21 | def test_mock_list(): 22 | response = client.get("/api/mock/list") 23 | assert response.status_code == 200 24 | data = response.json() 25 | assert isinstance(data, list) 26 | for item in data: 27 | assert 'serial' in item 28 | assert 'model' in item 29 | assert 'name' in item 30 | 31 | 32 | def test_mock_screenshot(): 33 | response = client.get("/api/mock/mock-serial/screenshot/0") 34 | assert response.status_code == 200 35 | assert response.headers['content-type'] == 'image/jpeg' 36 | 37 | 38 | def test_mock_hierarchy(): 39 | response = client.get("/api/mock/mock-serial/hierarchy") 40 | assert response.status_code == 200 41 | data = response.json() 42 | assert 'key' in data 43 | assert 'name' in data 44 | assert 'bounds' in data 45 | assert 'children' in data -------------------------------------------------------------------------------- /uiautodev/static/demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | AppInspector Demo 7 | 8 | 9 |

App Inspector

10 |
11 | 12 | 33 | 34 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "uiautodev" 3 | version = "0.0.0" 4 | description = "Mobile UI Automation, include UI hierarchy inspector, script recorder" 5 | homepage = "https://uiauto.dev" 6 | authors = ["codeskyblue "] 7 | license = "MIT" 8 | readme = "README.md" 9 | 10 | include = [ 11 | {path = "uiautodev/binaries/scrcpy.jar"} 12 | ] 13 | 14 | [tool.poetry.dependencies] 15 | python = "^3.8" 16 | adbutils = ">=2.8.10,<3" 17 | click = "^8.1.7" 18 | pygments = ">=2" 19 | uiautomator2 = ">=3.2.0,<4" 20 | fastapi = ">=0.115.12,<1" 21 | pydantic = "^2.6" 22 | wdapy = ">0.2.2,<1" 23 | websockets = ">=10.4" 24 | Pillow = ">=9" 25 | construct = "*" 26 | lxml = ">=6.0.2" 27 | httpx = ">=0.28.1" 28 | uvicorn = ">=0.33.0" 29 | rich = "*" 30 | python-multipart = ">=0.0.18" 31 | hypium = {version=">=6.0.7.200,<7.0.0", optional=true} 32 | 33 | [tool.poetry.extras] 34 | harmony = ["hypium"] 35 | 36 | [tool.poetry.scripts] 37 | "uiauto.dev" = "uiautodev.__main__:main" 38 | "uiautodev" = "uiautodev.__main__:main" 39 | 40 | [tool.poetry.group.dev.dependencies] 41 | pytest = "^8.0.1" 42 | isort = "^5.13.2" 43 | pytest-cov = "^4.1.0" 44 | 45 | [tool.poetry-dynamic-versioning] # 根据tag来动态配置版本号 46 | enable = true 47 | 48 | [build-system] 49 | requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] 50 | build-backend = "poetry_dynamic_versioning.backend" 51 | -------------------------------------------------------------------------------- /e2etests/test_harmony_driver.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | # 参考:https://github.com/codematrixer/awesome-hdc 4 | 5 | import pytest 6 | 7 | from uiautodev.driver.harmony import HDC, HarmonyDriver 8 | 9 | 10 | @pytest.fixture 11 | def hdc() -> HDC: 12 | return HDC() 13 | 14 | @pytest.fixture 15 | def serial(hdc: HDC) -> str: 16 | devices = hdc.list_device() 17 | assert len(devices) == 1 18 | return devices[0] 19 | 20 | 21 | def test_list_device(hdc: HDC): 22 | devices = hdc.list_device() 23 | assert len(devices) == 1 24 | 25 | 26 | def test_shell(hdc: HDC, serial: str): 27 | assert hdc.shell(serial, 'pwd') == '/' 28 | 29 | def test_get_model(hdc: HDC, serial: str): 30 | assert hdc.get_model(serial) == 'ohos' 31 | 32 | 33 | def test_screenshot(hdc: HDC, serial: str): 34 | image = hdc.screenshot(serial) 35 | assert image is not None 36 | assert image.size is not None 37 | 38 | 39 | def test_dump_layout(hdc: HDC, serial: str): 40 | layout = hdc.dump_layout(serial) 41 | assert layout is not None 42 | assert isinstance(layout, dict) 43 | 44 | 45 | @pytest.fixture 46 | def driver(hdc: HDC, serial: str) -> HarmonyDriver: 47 | return HarmonyDriver(hdc, serial) 48 | 49 | 50 | def test_window_size(driver: HarmonyDriver): 51 | size = driver.window_size() 52 | assert size.width > 0 53 | assert size.height > 0 54 | 55 | 56 | def test_dump_hierarchy(driver: HarmonyDriver): 57 | xml, hierarchy = driver.dump_hierarchy() 58 | assert xml is not None 59 | assert hierarchy is not None -------------------------------------------------------------------------------- /uiautodev/router/android.py: -------------------------------------------------------------------------------- 1 | # prefix for /api/android/{serial}/shell 2 | 3 | import logging 4 | from typing import Dict, Optional, Union 5 | 6 | from fastapi import APIRouter, Request, Response 7 | from pydantic import BaseModel 8 | 9 | from uiautodev.driver.android import ADBAndroidDriver, U2AndroidDriver 10 | from uiautodev.model import ShellResponse 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | router = APIRouter() 15 | 16 | 17 | class AndroidShellPayload(BaseModel): 18 | command: str 19 | 20 | @router.post("/{serial}/shell") 21 | def shell(serial: str, payload: AndroidShellPayload): 22 | """Run a shell command on an Android device""" 23 | try: 24 | driver = ADBAndroidDriver(serial) 25 | return driver.shell(payload.command) 26 | except NotImplementedError as e: 27 | return Response(content="shell not implemented", media_type="text/plain", status_code=501) 28 | except Exception as e: 29 | logger.exception("shell failed") 30 | return ShellResponse(output="", error=str(e)) 31 | 32 | 33 | @router.get("/{serial}/current_activity") 34 | async def get_current_activity(serial: str) -> Response: 35 | """Get the current activity of the Android device""" 36 | try: 37 | driver = ADBAndroidDriver(serial) 38 | activity = driver.get_current_activity() 39 | return Response(content=activity, media_type="text/plain") 40 | except Exception as e: 41 | logger.exception("get_current_activity failed") 42 | return Response(content="", media_type="text/plain") -------------------------------------------------------------------------------- /DEVELOP.md: -------------------------------------------------------------------------------- 1 | # For Developers 2 | 写的不是很全,请见谅。另外国外朋友,还请自行翻译一下。 3 | 4 | 目录结构 5 | 6 | - binaries: 二进制文件 7 | - driver: 不同类型设备的驱动 8 | - remote: 由于这块代码较多,单独从driver中拿出来了 9 | - router: 将驱动包括成接口透出 10 | 11 | - app.py: 相当于FastAPI入口文件 12 | - cli.py: 命令行相关 13 | 14 | 目前启动后的端口是固定的20242 (也就是2024年2月开始开发的意思) 15 | 16 | ## Mac or Linux环境配置 17 | 18 | ```bash 19 | # install poetry (python package manager) 20 | pip install poetry # pipx install poetry 21 | 22 | # install deps 23 | poetry install 24 | 25 | # format import 26 | make format 27 | 28 | # run server 29 | make dev 30 | 31 | # If you encounter the error NameError: name 'int2byte' is not defined, 32 | # try installing a stable version of the construct package to resolve it: 33 | # and restart: make dev 34 | pip install construct==2.9.45 35 | ``` 36 | 37 | ## Windows环境配置 38 | 39 | ```bash 40 | # install poetry (python package manager) 41 | pip install poetry # pipx install poetry 42 | 43 | # install deps 44 | poetry install 45 | 46 | # install make, choco ref: https://community.chocolatey.org/install 47 | # Set-ExecutionPolicy Bypass -Scope Process -Force; 48 | # [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; 49 | # iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) 50 | choco install make 51 | 52 | # format import 53 | make format 54 | 55 | # run server 56 | make dev 57 | 58 | # If you encounter the error NameError: name 'int2byte' is not defined, 59 | # try installing a stable version of the construct package to resolve it: 60 | # and restart: make dev 61 | pip install construct==2.9.45 62 | ``` 63 | 64 | 运行测试 65 | 66 | ```sh 67 | make test 68 | ``` -------------------------------------------------------------------------------- /uiautodev/common.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Thu May 09 2024 11:33:17 by codeskyblue 5 | """ 6 | 7 | 8 | import io 9 | import locale 10 | import logging 11 | from typing import List, Optional 12 | 13 | from PIL import Image 14 | 15 | from uiautodev.model import Node, OCRNode 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | def is_chinese_language() -> bool: 20 | language_code, _ = locale.getdefaultlocale() 21 | 22 | # Check if the language code starts with 'zh' (Chinese) 23 | if language_code and language_code.startswith('zh'): 24 | return True 25 | else: 26 | return False 27 | 28 | 29 | def get_webpage_url(web_url: Optional[str] = None) -> str: 30 | if not web_url: 31 | web_url = "https://uiauto.dev" 32 | # code will be enabled until uiauto.devsleep.com is ready 33 | # if is_chinese_language(): 34 | # web_url = "https://uiauto.devsleep.com" 35 | return web_url 36 | 37 | 38 | def convert_bytes_to_image(byte_data: bytes) -> Image.Image: 39 | return Image.open(io.BytesIO(byte_data)) 40 | 41 | 42 | def ocr_image(image: Image.Image) -> List[OCRNode]: 43 | # Placeholder for OCR implementation 44 | w, h = image.size 45 | try: 46 | from ocrmac import ocrmac 47 | except ImportError: 48 | logger.error("OCR is not supported on this platform") 49 | return [] 50 | result = ocrmac.OCR(image).recognize() 51 | nodes = [] 52 | for index, (text, confidence, pbounds) in enumerate(result): 53 | print(f"OCR result: {text}, confidence: {confidence}, bounds: {pbounds}") 54 | # bounds = int(pbounds[0]*w), int(pbounds[1]*h), int(pbounds[2]*w), int(pbounds[3]*h) 55 | nodes.append(OCRNode(key=str(index), name=text, bounds=pbounds, confidence=confidence)) 56 | return nodes -------------------------------------------------------------------------------- /tests/test_android.py: -------------------------------------------------------------------------------- 1 | from uiautodev.driver.android import parse_xml 2 | from uiautodev.model import WindowSize 3 | 4 | xml = """ 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | """ 15 | 16 | def test_parse_xml(): 17 | node = parse_xml(xml.strip(), WindowSize(1000, 1000)) 18 | assert node.name == "hierarchy" 19 | assert len(node.children) == 1 20 | assert node.rect is None 21 | 22 | childnode = node.children[0] 23 | assert childnode.name == "android.widget.FrameLayout" 24 | assert len(childnode.children) == 2 25 | assert childnode.rect is not None 26 | assert childnode.rect.x == 20 27 | assert childnode.rect.y == 30 28 | assert childnode.rect.width == 1000 29 | assert childnode.rect.height == 2000 30 | 31 | 32 | def test_parse_xml_display_id(): 33 | node = parse_xml(xml.strip(), WindowSize(1000, 1000), display_id=0) 34 | assert node.name == "hierarchy" 35 | assert len(node.children) == 1 36 | 37 | childnode = node.children[0] 38 | assert childnode.name == "android.widget.FrameLayout" 39 | assert len(childnode.children) == 1 -------------------------------------------------------------------------------- /chrome_plugin/background.js: -------------------------------------------------------------------------------- 1 | (function mainBackground() { 2 | console.log("mainBackground") 3 | const port = 20242; 4 | 5 | function _arrayBufferToBase64(buffer) { 6 | var binary = ''; 7 | var bytes = new Uint8Array(buffer); 8 | var len = bytes.byteLength; 9 | for (var i = 0; i < len; i++) { 10 | binary += String.fromCharCode(bytes[i]); 11 | } 12 | return btoa(binary); 13 | } 14 | 15 | async function fetchByMessage(message) { 16 | const response = await fetch(`http://localhost:${port}/${message.url.replace(/^\//, '')}`, { 17 | method: message.method || "GET", 18 | cache: "no-cache", 19 | headers: { 20 | "Content-Type": "application/json", 21 | }, 22 | body: message.body && JSON.stringify(message.body) 23 | }); 24 | if (response.headers.get("content-type") == "application/json") { 25 | return response.json(); 26 | } else { 27 | return response.arrayBuffer().then(buffer => { 28 | return _arrayBufferToBase64(buffer); 29 | }); 30 | } 31 | } 32 | 33 | chrome.runtime.onInstalled.addListener(async () => { 34 | chrome.storage.sync.set({ port }); 35 | console.log(`[uiauto.dev] default client port is set to: ${port}`); 36 | try { 37 | const data = await fetchByMessage({ url: "/info" }); 38 | console.log(JSON.stringify(data)); 39 | } catch (error) { 40 | console.log("error:", error) 41 | } 42 | }); 43 | 44 | chrome.runtime.onMessageExternal.addListener(async (message, sender, callback) => { 45 | try { 46 | const data = await fetchByMessage(message) 47 | callback({ error: null, data }) 48 | } catch (error) { 49 | callback({ error: error + "" }) 50 | } 51 | }) 52 | })(); 53 | 54 | -------------------------------------------------------------------------------- /uiautodev/appium_proxy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Tue Mar 19 2024 22:23:37 by codeskyblue 5 | """ 6 | 7 | import sys 8 | 9 | import httpx 10 | from fastapi import FastAPI, Request, Response 11 | 12 | app = FastAPI() 13 | 14 | 15 | # Retrieve the target URL from the command line arguments 16 | try: 17 | TARGET_URL = sys.argv[1] 18 | except IndexError: 19 | print("Usage: python proxy_server.py ") 20 | sys.exit(1) 21 | 22 | @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE"]) 23 | async def proxy(request: Request, path: str): 24 | # Construct the full URL to forward the request to 25 | if path.endswith('/execute/sync'): 26 | # 旧版appium处理不好这个请求,直接返回404, unknown command 27 | # 目前browserstack也不支持这个请求 28 | return Response(content=b'{"value": {"error": "unknown command", "message": "unknown command", "stacktrace": "UnknownCommandError"}}', status_code=404) 29 | full_url = f"{TARGET_URL}/{path}" 30 | body = await request.body() 31 | print("Forwarding to", request.method, full_url) 32 | print("==> BODY <==") 33 | print(body) 34 | # Include original headers in the request 35 | headers = {k: v for k, v in request.headers.items() if k != 'host'} 36 | 37 | # Forward the request to the target server 38 | async with httpx.AsyncClient(timeout=120) as client: 39 | resp = await client.request( 40 | method=request.method, 41 | url=full_url, 42 | headers=headers, 43 | data=body, 44 | follow_redirects=True, 45 | ) 46 | 47 | # Return the response received from the target server 48 | return Response(content=resp.content, status_code=resp.status_code, headers=dict(resp.headers)) 49 | 50 | 51 | if __name__ == "__main__": 52 | import uvicorn 53 | uvicorn.run(app, host="0.0.0.0", port=8000) 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # uiautodev 2 | [![codecov](https://codecov.io/gh/codeskyblue/appinspector/graph/badge.svg?token=aLTg4VOyQH)](https://codecov.io/gh/codeskyblue/appinspector) 3 | [![PyPI version](https://badge.fury.io/py/uiautodev.svg)](https://badge.fury.io/py/uiautodev) 4 | 5 | https://uiauto.dev 6 | 7 | > ~~In China visit: https://uiauto.devsleep.com~~ 8 | 9 | UI Inspector for Android, iOS and Harmony help inspector element properties, and auto generate XPath, script. 10 | 11 | # Install 12 | ```bash 13 | pip install uiautodev 14 | 15 | # or with Harmony support 16 | pip install "uiautodev[harmony]" 17 | # ref 18 | # https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hypium-python-guidelines 19 | ``` 20 | 21 | # Usage 22 | ```bash 23 | Usage: uiauto.dev [OPTIONS] COMMAND [ARGS]... 24 | 25 | Options: 26 | -v, --verbose verbose mode 27 | -h, --help Show this message and exit. 28 | 29 | Commands: 30 | server start uiauto.dev local server [Default] 31 | android COMMAND: tap, tapElement, installApp, currentApp,... 32 | ios COMMAND: tap, tapElement, installApp, currentApp,... 33 | self-update Update uiautodev to latest version 34 | version Print version 35 | shutdown Shutdown server 36 | ``` 37 | 38 | ```bash 39 | # run local server and open browser 40 | uiauto.dev 41 | ``` 42 | 43 | # Environment 44 | 45 | ```sh 46 | # Default driver is uiautomator2 47 | # Set the environment variable below to switch to adb driver 48 | export UIAUTODEV_USE_ADB_DRIVER=1 49 | ``` 50 | 51 | # Offline mode 52 | 53 | Start with 54 | 55 | ```sh 56 | uiautodev server --offline 57 | 58 | # Specify server url (optional) 59 | uiautodev server --offline --server-url https://uiauto.dev 60 | ``` 61 | 62 | Visit once, and then disconnecting from the internet will not affect usage. 63 | 64 | > All frontend resources will be saved to cache/ dir. 65 | 66 | # DEVELOP 67 | 68 | see [DEVELOP.md](DEVELOP.md) 69 | 70 | # Links 71 | - https://app.tangoapp.dev/ 基于webadb的手机远程控制项目 72 | - https://docs.tangoapp.dev/scrcpy/video/web-codecs/ H264解码器 73 | 74 | # LICENSE 75 | [MIT](LICENSE) 76 | -------------------------------------------------------------------------------- /uiautodev/driver/android/u2_driver.py: -------------------------------------------------------------------------------- 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() -------------------------------------------------------------------------------- /uiautodev/driver/android/common.py: -------------------------------------------------------------------------------- 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 62 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Python Package 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - 'docs/**' 7 | branches: 8 | - master 9 | - dev 10 | pull_request: 11 | paths-ignore: 12 | - 'docs/**' 13 | branches: 14 | - '**' 15 | 16 | concurrency: 17 | group: tests-${{ github.head_ref || github.ref }} 18 | cancel-in-progress: ${{ github.event_name == 'pull_request' }} 19 | 20 | jobs: 21 | test: 22 | name: ${{ matrix.os }} / ${{ matrix.python-version }} 23 | runs-on: ${{ matrix.image }} 24 | strategy: 25 | matrix: 26 | os: [Ubuntu, macOS, Windows] 27 | python-version: ["3.8", "3.11"] 28 | include: 29 | - os: Ubuntu 30 | image: ubuntu-22.04 31 | - os: Windows 32 | image: windows-2022 33 | - os: macOS 34 | image: macos-15 35 | fail-fast: false 36 | steps: 37 | - uses: actions/checkout@v4 38 | 39 | - name: Set up Python ${{ matrix.python-version }} 40 | uses: actions/setup-python@v4 41 | with: 42 | python-version: ${{ matrix.python-version }} 43 | 44 | - name: Get full Python version 45 | id: full-python-version 46 | run: echo version=$(python -c "import sys; print('-'.join(str(v) for v in sys.version_info))") >> $GITHUB_OUTPUT 47 | 48 | - name: Update PATH 49 | if: ${{ matrix.os != 'Windows' }} 50 | run: echo "$HOME/.local/bin" >> $GITHUB_PATH 51 | 52 | - name: Update Path for Windows 53 | if: ${{ matrix.os == 'Windows' }} 54 | run: echo "$APPDATA\Python\Scripts" >> $GITHUB_PATH 55 | 56 | - name: Enable long paths for git on Windows 57 | if: ${{ matrix.os == 'Windows' }} 58 | # Enable handling long path names (+260 char) on the Windows platform 59 | # https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file#maximum-path-length-limitation 60 | run: git config --system core.longpaths true 61 | 62 | - name: Install dependencies 63 | run: | 64 | python -m pip install --upgrade pip 65 | pip install poetry 66 | poetry install 67 | 68 | - name: Run tests with coverage 69 | run: | 70 | poetry run pytest --cov=. --cov-report xml --cov-report term 71 | 72 | - name: Upload coverage to Codecov 73 | uses: codecov/codecov-action@v3 -------------------------------------------------------------------------------- /tests/test_touch_controller.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from unittest.mock import MagicMock 3 | 4 | import pytest 5 | 6 | from uiautodev.remote.android_input import KeyeventAction, MetaState 7 | from uiautodev.remote.keycode import KeyCode 8 | from uiautodev.remote.touch_controller import KeyEvent, MessageType, ScrcpyTouchController 9 | 10 | 11 | @pytest.fixture 12 | def controller(): 13 | # Create a mock socket for testing 14 | mock_socket = MagicMock() 15 | # Create the controller with the mock socket 16 | controller = ScrcpyTouchController( 17 | mock_socket 18 | ) 19 | 20 | # Add the mock_socket as an attribute for assertions 21 | controller.mock_socket = mock_socket 22 | 23 | return controller 24 | 25 | 26 | # Screen dimensions for testing 27 | WIDTH = 1080 28 | HEIGHT = 1920 29 | 30 | 31 | def test_down(controller: ScrcpyTouchController): 32 | """Test the down method sends the correct data""" 33 | # Test coordinates 34 | x, y = 100, 200 35 | 36 | # Call the down method 37 | controller.down(x, y, WIDTH, HEIGHT) 38 | 39 | # Verify that send was called once 40 | controller.mock_socket.send.assert_called_once() 41 | 42 | # Get the data that was sent 43 | sent_data = controller.mock_socket.send.call_args[0][0] 44 | assert sent_data == bytes([ 45 | 0x02, # SC_CONTROL_MSG_TYPE_INJECT_TOUCH_EVENT 46 | 0x00, # AKEY_EVENT_ACTION_DOWN 47 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, #12, 0x34, 0x56, 0x78, 0x87, 0x65, 0x43, 0x21, # pointer_id 48 | 0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0xc8, # 100 200 49 | 0x04, 0x38, 0x07, 0x80, # width, height 50 | 0x00, 0x01, # pressure 51 | 0x00, 0x00, 0x00, 0x01, # action_button 52 | 0x00, 0x00, 0x00, 0x01, # buttons 53 | ]) 54 | 55 | 56 | def test_key(controller): 57 | controller.key(KeyeventAction.UP, KeyCode.ENTER, 5, MetaState.SHIFT_ON | MetaState.SHIFT_LEFT_ON) 58 | controller.mock_socket.send.assert_called_once() 59 | 60 | sent_data = controller.mock_socket.send.call_args[0][0] 61 | assert sent_data == bytes([ 62 | 0x00, # SC_CONTROL_MSG_TYPE_INJECT_KEYCODE 63 | 0x01, # AKEY_EVENT_ACTION_UP 64 | 0x00, 0x00, 0x00, 0x42, # AKEYCODE_ENTER 65 | 0x00, 0x00, 0x00, 0X05, # repeat 66 | 0x00, 0x00, 0x00, 0x41, # AMETA_SHIFT_ON | AMETA_SHIFT_LEFT_ON 67 | ]) -------------------------------------------------------------------------------- /uiautodev/command_types.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Tue Mar 19 2024 10:19:27 by codeskyblue 5 | """ 6 | 7 | 8 | # Request and Response 9 | import enum 10 | from typing import List, Optional, Union 11 | 12 | from pydantic import BaseModel 13 | 14 | from uiautodev.model import Node 15 | 16 | 17 | # POST /api/v1/device/{serial}/command/{command} 18 | class Command(str, enum.Enum): 19 | TAP = "tap" 20 | TAP_ELEMENT = "tapElement" 21 | APP_INSTALL = "installApp" 22 | APP_CURRENT = "currentApp" 23 | APP_LAUNCH = "appLaunch" 24 | APP_TERMINATE = "appTerminate" 25 | APP_LIST = "appList" 26 | 27 | GET_WINDOW_SIZE = "getWindowSize" 28 | HOME = "home" 29 | DUMP = "dump" 30 | WAKE_UP = "wakeUp" 31 | FIND_ELEMENTS = "findElements" 32 | CLICK_ELEMENT = "clickElement" 33 | 34 | LIST = "list" 35 | 36 | # 0.4.0 37 | BACK = "back" 38 | APP_SWITCH = "appSwitch" 39 | VOLUME_UP = "volumeUp" 40 | VOLUME_DOWN = "volumeDown" 41 | VOLUME_MUTE = "volumeMute" 42 | SEND_KEYS = "sendKeys" 43 | CLEAR_TEXT = "clearText" 44 | 45 | 46 | class TapRequest(BaseModel): 47 | x: Union[int, float] 48 | y: Union[int, float] 49 | isPercent: bool = False 50 | 51 | 52 | class InstallAppRequest(BaseModel): 53 | url: str 54 | 55 | 56 | class InstallAppResponse(BaseModel): 57 | success: bool 58 | id: Optional[str] = None 59 | 60 | 61 | class CurrentAppResponse(BaseModel): 62 | package: str 63 | activity: Optional[str] = None 64 | pid: Optional[int] = None 65 | 66 | 67 | class AppLaunchRequest(BaseModel): 68 | package: str 69 | stop: bool = False 70 | 71 | 72 | class AppTerminateRequest(BaseModel): 73 | package: str 74 | 75 | 76 | class WindowSizeResponse(BaseModel): 77 | width: int 78 | height: int 79 | 80 | 81 | class DumpResponse(BaseModel): 82 | value: str 83 | 84 | 85 | class By(str, enum.Enum): 86 | ID = "id" 87 | TEXT = "text" 88 | XPATH = "xpath" 89 | CLASS_NAME = "className" 90 | 91 | class FindElementRequest(BaseModel): 92 | by: str 93 | value: str 94 | timeout: float = 10.0 95 | 96 | 97 | class FindElementResponse(BaseModel): 98 | count: int 99 | value: List[Node] 100 | 101 | 102 | class SendKeysRequest(BaseModel): 103 | text: str -------------------------------------------------------------------------------- /uiautodev/driver/mock.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Mon Mar 04 2024 14:10:00 by codeskyblue 5 | """ 6 | 7 | from PIL import Image, ImageDraw 8 | 9 | from uiautodev.driver.base_driver import BaseDriver 10 | from uiautodev.model import Node, ShellResponse, WindowSize 11 | 12 | 13 | class MockDriver(BaseDriver): 14 | def screenshot(self, id: int): 15 | im = Image.new("RGB", (500, 800), "gray") 16 | draw = ImageDraw.Draw(im) 17 | draw.text((10, 10), "mock", fill="white") 18 | draw.rectangle([100, 100, 200, 200], outline="red", fill="blue") 19 | del draw 20 | return im 21 | 22 | def dump_hierarchy(self): 23 | return "", Node( 24 | key="0", 25 | name="root", 26 | bounds=(0, 0, 1, 1), 27 | properties={ 28 | "class": "android.view.View", 29 | }, 30 | children=[ 31 | Node( 32 | key="0-0", 33 | name="mock1", 34 | bounds=(0.1, 0.1, 0.5, 0.5), 35 | properties={ 36 | "class": "android.widget.FrameLayout", 37 | "text": "mock1", 38 | "accessible": "true", 39 | }, 40 | ), 41 | Node( 42 | key="0-1", 43 | name="mock2", 44 | bounds=(0.4, 0.4, 0.6, 0.6), 45 | properties={ 46 | "class": "android.widget.ImageView", 47 | "text": "mock2", 48 | "accessible": "true", 49 | }, 50 | children=[ 51 | Node( 52 | key="0-1-0", 53 | name="mock2-1", 54 | bounds=(0.42, 0.42, 0.45, 0.45), 55 | properties={ 56 | "class": "android.widget.ImageView", 57 | "text": "mock2-1", 58 | "visible": "true", 59 | }, 60 | ), 61 | ] 62 | ), 63 | Node( 64 | key="0-2", 65 | name="mock-should-not-show", 66 | bounds=(0.4, 0.4, 0.6, 0.6), 67 | properties={ 68 | "class": "android.widget.ImageView", 69 | "text": "mock3", 70 | "visible": "false", 71 | }, 72 | ), 73 | ], 74 | ) 75 | -------------------------------------------------------------------------------- /uiautodev/remote/android_input.py: -------------------------------------------------------------------------------- 1 | # Ref 2 | # https://github.com/Genymobile/scrcpy/blob/master/app/src/android/input.h 3 | from enum import IntEnum 4 | 5 | 6 | class MetaState(IntEnum): 7 | """Android meta state flags ported from Android's KeyEvent class 8 | 9 | These flags represent the state of meta keys such as ALT, SHIFT, CTRL, etc. 10 | They can be combined using bitwise OR operations to represent multiple 11 | meta keys being pressed simultaneously. 12 | 13 | The values and comments are taken directly from the Android source code 14 | to maintain compatibility and provide accurate descriptions. 15 | """ 16 | # No meta keys are pressed 17 | NONE = 0x0 18 | 19 | # This mask is used to check whether one of the SHIFT meta keys is pressed 20 | SHIFT_ON = 0x1 21 | 22 | # This mask is used to check whether one of the ALT meta keys is pressed 23 | ALT_ON = 0x2 24 | 25 | # This mask is used to check whether the SYM meta key is pressed 26 | SYM_ON = 0x4 27 | 28 | # This mask is used to check whether the FUNCTION meta key is pressed 29 | FUNCTION_ON = 0x8 30 | 31 | # This mask is used to check whether the left ALT meta key is pressed 32 | ALT_LEFT_ON = 0x10 33 | 34 | # This mask is used to check whether the right ALT meta key is pressed 35 | ALT_RIGHT_ON = 0x20 36 | 37 | # This mask is used to check whether the left SHIFT meta key is pressed 38 | SHIFT_LEFT_ON = 0x40 39 | 40 | # This mask is used to check whether the right SHIFT meta key is pressed 41 | SHIFT_RIGHT_ON = 0x80 42 | 43 | # This mask is used to check whether the CAPS LOCK meta key is on 44 | CAPS_LOCK_ON = 0x100000 45 | 46 | # This mask is used to check whether the NUM LOCK meta key is on 47 | NUM_LOCK_ON = 0x200000 48 | 49 | # This mask is used to check whether the SCROLL LOCK meta key is on 50 | SCROLL_LOCK_ON = 0x400000 51 | 52 | # This mask is used to check whether one of the CTRL meta keys is pressed 53 | CTRL_ON = 0x1000 54 | 55 | # This mask is used to check whether the left CTRL meta key is pressed 56 | CTRL_LEFT_ON = 0x2000 57 | 58 | # This mask is used to check whether the right CTRL meta key is pressed 59 | CTRL_RIGHT_ON = 0x4000 60 | 61 | # This mask is used to check whether one of the META meta keys is pressed 62 | META_ON = 0x10000 63 | 64 | # This mask is used to check whether the left META meta key is pressed 65 | META_LEFT_ON = 0x20000 66 | 67 | # This mask is used to check whether the right META meta key is pressed 68 | META_RIGHT_ON = 0x40000 69 | 70 | 71 | class KeyeventAction(IntEnum): 72 | DOWN = 0 73 | UP = 1 74 | MULTIPLE = 2 75 | -------------------------------------------------------------------------------- /uiautodev/remote/scrcpy3.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from pathlib import Path 3 | import socket 4 | from adbutils import AdbConnection, AdbDevice, AdbError, Network 5 | from fastapi import WebSocket 6 | from retry import retry 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class ScrcpyServer3: 12 | VERSION = "3.3.3" 13 | 14 | def __init__(self, device: AdbDevice): 15 | self._device = device 16 | self._shell_conn: AdbConnection 17 | self._video_sock: socket.socket 18 | self._control_sock: socket.socket 19 | 20 | self._shell_conn = self._start_scrcpy3() 21 | self._video_sock = self._connect_scrcpy(dummy_byte=True) 22 | self._control_sock = self._connect_scrcpy() 23 | 24 | def _start_scrcpy3(self): 25 | device = self._device 26 | jar_path = Path(__file__).parent.joinpath(f'../binaries/scrcpy-server-v{self.VERSION}.jar') 27 | device.sync.push(jar_path, '/data/local/tmp/scrcpy_server.jar', check=True) 28 | logger.info(f'{jar_path.name} pushed to device') 29 | 30 | # 构建启动 scrcpy 服务器的命令 31 | cmds = [ 32 | 'CLASSPATH=/data/local/tmp/scrcpy_server.jar', 33 | 'app_process', '/', 34 | f'com.genymobile.scrcpy.Server', self.VERSION, 35 | 'log_level=info', 'max_size=1024', 'max_fps=30', 36 | 'video_bit_rate=8000000', 'tunnel_forward=true', 37 | 'send_frame_meta=true', 38 | f'control=true', 39 | 'audio=false', 'show_touches=false', 'stay_awake=false', 40 | 'power_off_on_close=false', 'clipboard_autosync=false' 41 | ] 42 | conn = device.shell(cmds, stream=True) 43 | logger.debug("scrcpy output: %s", conn.conn.recv(100)) 44 | return conn 45 | 46 | @retry(exceptions=AdbError, tries=20, delay=0.1) 47 | def _connect_scrcpy(self, dummy_byte: bool = False) -> socket.socket: 48 | sock = self._device.create_connection(Network.LOCAL_ABSTRACT, 'scrcpy') 49 | if dummy_byte: 50 | received = sock.recv(1) 51 | if not received or received != b"\x00": 52 | raise ConnectionError("Did not receive Dummy Byte!") 53 | logger.debug('Received Dummy Byte!') 54 | return sock 55 | 56 | def stream_to_websocket(self, ws: WebSocket): 57 | from .pipe import RWSocketDuplex, WebSocketDuplex, AsyncDuplex, pipe_duplex 58 | socket_duplex = RWSocketDuplex(self._video_sock, self._control_sock) 59 | websocket_duplex = WebSocketDuplex(ws) 60 | return pipe_duplex(socket_duplex, websocket_duplex) 61 | 62 | def close(self): 63 | self._safe_close_sock(self._control_sock) 64 | self._safe_close_sock(self._video_sock) 65 | self._shell_conn.close() 66 | 67 | def _safe_close_sock(self, sock: socket.socket): 68 | try: 69 | sock.close() 70 | except: 71 | pass 72 | -------------------------------------------------------------------------------- /uiautodev/driver/base_driver.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Fri Mar 01 2024 14:18:30 by codeskyblue 5 | """ 6 | import abc 7 | from typing import Iterator, List, Tuple 8 | 9 | from PIL import Image 10 | 11 | from uiautodev.command_types import CurrentAppResponse 12 | from uiautodev.model import AppInfo, Node, ShellResponse, WindowSize 13 | 14 | 15 | class BaseDriver(abc.ABC): 16 | def __init__(self, serial: str): 17 | self.serial = serial 18 | 19 | @abc.abstractmethod 20 | def screenshot(self, id: int) -> Image.Image: 21 | """Take a screenshot of the device 22 | :param id: physical display ID to capture (normally: 0) 23 | :return: PIL.Image.Image 24 | """ 25 | raise NotImplementedError() 26 | 27 | @abc.abstractmethod 28 | def dump_hierarchy(self) -> Tuple[str, Node]: 29 | """Dump the view hierarchy of the device 30 | :return: xml_source, Hierarchy 31 | """ 32 | raise NotImplementedError() 33 | 34 | def shell(self, command: str) -> ShellResponse: 35 | """Run a shell command on the device 36 | :param command: shell command 37 | :return: ShellResponse 38 | """ 39 | raise NotImplementedError() 40 | 41 | def tap(self, x: int, y: int): 42 | """Tap on the screen 43 | :param x: x coordinate 44 | :param y: y coordinate 45 | """ 46 | raise NotImplementedError() 47 | 48 | def window_size(self) -> WindowSize: 49 | """ get window UI size """ 50 | raise NotImplementedError() 51 | 52 | def app_install(self, app_path: str): 53 | """ install app """ 54 | raise NotImplementedError() 55 | 56 | def app_current(self) -> CurrentAppResponse: 57 | """ get current app """ 58 | raise NotImplementedError() 59 | 60 | def app_launch(self, package: str): 61 | """ launch app """ 62 | raise NotImplementedError() 63 | 64 | def app_terminate(self, package: str): 65 | """ terminate app """ 66 | raise NotImplementedError() 67 | 68 | def home(self): 69 | """ press home button """ 70 | raise NotImplementedError() 71 | 72 | def back(self): 73 | """ press back button """ 74 | raise NotImplementedError() 75 | 76 | def app_switch(self): 77 | """ switch app """ 78 | raise NotImplementedError() 79 | 80 | def volume_up(self): 81 | """ volume up """ 82 | raise NotImplementedError() 83 | 84 | def volume_down(self): 85 | """ volume down """ 86 | raise NotImplementedError() 87 | 88 | def volume_mute(self): 89 | """ volume mute """ 90 | raise NotImplementedError() 91 | 92 | def wake_up(self): 93 | """ wake up the device """ 94 | raise NotImplementedError() 95 | 96 | def app_list(self) -> List[AppInfo]: 97 | """ list installed packages """ 98 | raise NotImplementedError() 99 | 100 | def open_app_file(self, package: str) -> Iterator[bytes]: 101 | """ open app file """ 102 | raise NotImplementedError() 103 | 104 | def send_keys(self, text: str): 105 | """ send keys to device """ 106 | raise NotImplementedError() 107 | 108 | def clear_text(self): 109 | """ clear text input on device """ 110 | raise NotImplementedError() 111 | -------------------------------------------------------------------------------- /uiautodev/provider.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Sun Feb 18 2024 11:10:58 by codeskyblue 5 | """ 6 | from __future__ import annotations 7 | 8 | import abc 9 | from functools import lru_cache 10 | from typing import Type 11 | 12 | import adbutils 13 | 14 | from uiautodev.driver.android import ADBAndroidDriver, U2AndroidDriver 15 | from uiautodev.driver.base_driver import BaseDriver 16 | from uiautodev.driver.harmony import HDC, HarmonyDriver 17 | from uiautodev.driver.ios import IOSDriver 18 | from uiautodev.driver.mock import MockDriver 19 | from uiautodev.exceptions import UiautoException 20 | from uiautodev.model import DeviceInfo 21 | from uiautodev.utils.usbmux import MuxDevice, list_devices 22 | 23 | 24 | class BaseProvider(abc.ABC): 25 | @abc.abstractmethod 26 | def list_devices(self) -> list[DeviceInfo]: 27 | raise NotImplementedError() 28 | 29 | @abc.abstractmethod 30 | def get_device_driver(self, serial: str) -> BaseDriver: 31 | raise NotImplementedError() 32 | 33 | def get_single_device_driver(self) -> BaseDriver: 34 | """ debug use """ 35 | devs = self.list_devices() 36 | if len(devs) == 0: 37 | raise UiautoException("No device found") 38 | if len(devs) > 1: 39 | raise UiautoException("More than one device found") 40 | return self.get_device_driver(devs[0].serial) 41 | 42 | 43 | class AndroidProvider(BaseProvider): 44 | def __init__(self, driver_class: Type[BaseDriver] = U2AndroidDriver): 45 | self.driver_class = driver_class 46 | 47 | def list_devices(self) -> list[DeviceInfo]: 48 | adb = adbutils.AdbClient() 49 | ret: list[DeviceInfo] = [] 50 | for d in adb.list(extended=True): 51 | if d.state != "device": 52 | ret.append(DeviceInfo(serial=d.serial, status=d.state, enabled=False)) 53 | else: 54 | ret.append(DeviceInfo( 55 | serial=d.serial, 56 | status=d.state, 57 | name=d.tags.get('device', ''), 58 | model=d.tags.get('model', ''), 59 | product=d.tags.get('product', ''), 60 | enabled=True 61 | )) 62 | return ret 63 | 64 | @lru_cache 65 | def get_device_driver(self, serial: str) -> BaseDriver: 66 | return self.driver_class(serial) 67 | 68 | 69 | 70 | class IOSProvider(BaseProvider): 71 | def list_devices(self) -> list[DeviceInfo]: 72 | devs = list_devices() 73 | return [DeviceInfo(serial=d.serial, model="unknown", name="unknown") for d in devs] 74 | 75 | @lru_cache 76 | def get_device_driver(self, serial: str) -> BaseDriver: 77 | return IOSDriver(serial) 78 | 79 | 80 | class HarmonyProvider(BaseProvider): 81 | def __init__(self): 82 | super().__init__() 83 | self.hdc = HDC() 84 | 85 | def list_devices(self) -> list[DeviceInfo]: 86 | devices = self.hdc.list_device() 87 | return [DeviceInfo(serial=d, model=self.hdc.get_model(d), name=self.hdc.get_name(d)) for d in devices] 88 | 89 | @lru_cache 90 | def get_device_driver(self, serial: str) -> HarmonyDriver: 91 | return HarmonyDriver(self.hdc, serial) 92 | 93 | 94 | class MockProvider(BaseProvider): 95 | def list_devices(self) -> list[DeviceInfo]: 96 | return [DeviceInfo(serial="mock-serial", model="mock-model", name="mock-name")] 97 | 98 | def get_device_driver(self, serial: str) -> BaseDriver: 99 | return MockDriver(serial) 100 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | .idea/ 161 | 162 | poetry.lock 163 | window_dump.xml 164 | .DS_Store 165 | cache/ 166 | -------------------------------------------------------------------------------- /uiautodev/case.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Sat Apr 13 2024 22:35:03 by codeskyblue 5 | """ 6 | 7 | import enum 8 | import logging 9 | from typing import Dict, Union 10 | 11 | from pydantic import BaseModel 12 | 13 | from uiautodev import command_proxy 14 | from uiautodev.command_types import Command 15 | from uiautodev.driver.base_driver import BaseDriver 16 | from uiautodev.provider import AndroidProvider 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | class CommandStep(BaseModel): 21 | method: Union[str, Command] 22 | params: Dict[str, str] 23 | skip: bool = False 24 | ignore_error: bool = False 25 | 26 | 27 | class CompareEnum(str, enum.Enum): 28 | EQUAL = "equal" 29 | CONTAINS = "contains" 30 | NOT_EQUAL = "not_equal" 31 | NOT_CONTAINS = "not_contains" 32 | 33 | 34 | class CompareCheckStep(BaseModel): 35 | method: CompareEnum 36 | value_a: str 37 | value_b: str 38 | skip: bool = False 39 | 40 | 41 | def run_driver_command(driver: BaseDriver, command: Command, params: dict): 42 | model = command_proxy.get_command_params_type(command) 43 | params_obj = model.model_validate(params) if params else None 44 | # print("Params:", params, params_obj) 45 | result = command_proxy.send_command(driver, command, params_obj) 46 | return result 47 | 48 | def run(): 49 | # all params key and value should be string 50 | # Step中 51 | # 入参类型在为前端保存一份,后端需要同步兼容 52 | # params所有的key和value都是string类型 53 | # 出参类型支持重命名 result 54 | # - key: string, old_key: string, desc: string 55 | # eg. "WIDTH", "width", "屏幕宽度" 56 | steps = [ 57 | CommandStep( 58 | method=Command.APP_LAUNCH, 59 | params= { 60 | "package": "com.saucelabs.mydemoapp.android", 61 | "stop": "true" # bool 62 | } 63 | ), 64 | CommandStep( 65 | method=Command.GET_WINDOW_SIZE, 66 | result_trans=[ 67 | dict(key="WIDTH", result_key="width", desc="屏幕宽度"), 68 | ] 69 | ), 70 | CommandStep( 71 | method=Command.ECHO, 72 | params={ 73 | "message": "WindowWidth is {{WIDTH}}", 74 | } 75 | ), 76 | CommandStep( 77 | method=Command.CLICK_ELEMENT, 78 | params={ 79 | "by": "id", 80 | "value": "com.saucelabs.mydemoapp.android:id/productIV", 81 | } 82 | ), 83 | CommandStep( 84 | method=Command.CLICK_ELEMENT, 85 | params={ 86 | "by": "id", 87 | "value": "com.saucelabs.mydemoapp.android:id/plusIV", 88 | } 89 | ), 90 | CommandStep( 91 | method=Command.CLICK_ELEMENT, 92 | params={ 93 | "by": "id", 94 | "value": "com.saucelabs.mydemoapp.android:id/cartBt", 95 | } 96 | ), 97 | CommandStep( 98 | method=Command.CLICK_ELEMENT, 99 | params={ 100 | "by": "id", 101 | "value": "com.saucelabs.mydemoapp.android:id/cartIV", 102 | } 103 | ), 104 | CommandStep( 105 | method=Command.FIND_ELEMENT, 106 | params={ 107 | "by": "text", 108 | "value": "Proceed To Checkout", 109 | }, 110 | skip=True, 111 | ), 112 | CompareCheckStep( 113 | method=CompareEnum.EQUAL, 114 | value_a="$.name", 115 | value_b="com.saucelabs.mydemoapp.android:id/cartIV", 116 | ), 117 | CommandStep( 118 | method=Command.CLICK_ELEMENT, 119 | params={ 120 | "by": "text", 121 | "value": "Proceed To Checkout", 122 | } 123 | ), 124 | ] 125 | provider = AndroidProvider() 126 | driver = provider.get_single_device_driver() 127 | local_vars: Dict[str, str] = {} 128 | for step in steps: 129 | if not isinstance(step, CommandStep): 130 | continue 131 | command = Command(step.method) 132 | params = step.params 133 | print(step.method, params) 134 | if step.skip: 135 | logger.debug("Skip step: %s", step.method) 136 | continue 137 | run_driver_command(driver, command, params) -------------------------------------------------------------------------------- /uiautodev/driver/ios.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Fri Mar 01 2024 14:35:46 by codeskyblue 5 | """ 6 | 7 | 8 | import json 9 | from functools import partial 10 | from typing import List, Optional, Tuple 11 | from xml.etree import ElementTree 12 | 13 | import wdapy 14 | from PIL import Image 15 | 16 | from uiautodev.command_types import CurrentAppResponse 17 | from uiautodev.driver.base_driver import BaseDriver 18 | from uiautodev.exceptions import IOSDriverException 19 | from uiautodev.model import Node, WindowSize 20 | from uiautodev.utils.usbmux import select_device 21 | 22 | 23 | class IOSDriver(BaseDriver): 24 | def __init__(self, serial: str): 25 | """ serial is the udid of the ios device """ 26 | super().__init__(serial) 27 | self.device = select_device(serial) 28 | self.wda = wdapy.AppiumUSBClient(self.device.serial) 29 | 30 | def _request(self, method: str, path: str, payload: Optional[dict] = None) -> bytes: 31 | conn = self.device.make_http_connection(port=8100) 32 | try: 33 | if payload is None: 34 | conn.request(method, path) 35 | else: 36 | conn.request(method, path, body=json.dumps(payload), headers={"Content-Type": "application/json"}) 37 | response = conn.getresponse() 38 | if response.getcode() != 200: 39 | raise IOSDriverException(f"Failed request to device, status: {response.getcode()}") 40 | content = bytearray() 41 | while chunk := response.read(4096): 42 | content.extend(chunk) 43 | return content 44 | finally: 45 | conn.close() 46 | 47 | def _request_json(self, method: str, path: str) -> dict: 48 | content = self._request(method, path) 49 | return json.loads(content) 50 | 51 | def _request_json_value(self, method: str, path: str) -> dict: 52 | return self._request_json(method, path)["value"] 53 | 54 | def status(self): 55 | return self._request_json("GET", "/status") 56 | 57 | def screenshot(self, id: int = 0) -> Image.Image: 58 | return self.wda.screenshot() 59 | 60 | def window_size(self): 61 | return self.wda.window_size() 62 | 63 | def dump_hierarchy(self) -> Tuple[str, Node]: 64 | """returns xml string and hierarchy object""" 65 | t = self.wda.sourcetree() 66 | xml_data = t.value 67 | root = ElementTree.fromstring(xml_data) 68 | return xml_data, parse_xml_element(root, WindowSize(width=1, height=1)) 69 | 70 | def tap(self, x: int, y: int): 71 | self.wda.tap(x, y) 72 | 73 | def app_current(self) -> CurrentAppResponse: 74 | info = self.wda.app_current() 75 | return CurrentAppResponse(package=info.bundle_id, pid=info.pid) 76 | 77 | def home(self): 78 | self.wda.homescreen() 79 | 80 | def app_switch(self): 81 | raise NotImplementedError() 82 | 83 | def volume_up(self): 84 | self.wda.volume_up() 85 | 86 | def volume_down(self): 87 | self.wda.volume_down() 88 | 89 | 90 | def parse_xml_element(element, wsize: WindowSize, indexes: List[int]=[0]) -> Node: 91 | """ 92 | Recursively parse an XML element into a dictionary format. 93 | # 94 | """ 95 | if element.attrib.get("visible") == "false": 96 | return None 97 | if element.tag == "XCUIElementTypeApplication": 98 | wsize = WindowSize(width=int(element.attrib["width"]), height=int(element.attrib["height"])) 99 | x = int(element.attrib.get("x", 0)) 100 | y = int(element.attrib.get("y", 0)) 101 | width = int(element.attrib.get("width", 0)) 102 | height = int(element.attrib.get("height", 0)) 103 | bounds = (x / wsize.width, y / wsize.height, (x + width) / wsize.width, (y + height) / wsize.height) 104 | bounds = list(map(partial(round, ndigits=4), bounds)) 105 | name = element.attrib.get("type", "XCUIElementTypeUnknown") 106 | 107 | elem = Node( 108 | key='-'.join(map(str, indexes)), 109 | name=name, 110 | bounds=bounds, 111 | properties={key: element.attrib[key] for key in element.attrib}, 112 | children=[], 113 | ) 114 | for index, child in enumerate(element): 115 | child_elem = parse_xml_element(child, wsize, indexes+[index]) 116 | if child_elem: 117 | elem.children.append(child_elem) 118 | return elem 119 | 120 | -------------------------------------------------------------------------------- /uiautodev/remote/pipe.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import socket 3 | from typing import Optional, Protocol 4 | from starlette.websockets import WebSocket, WebSocketDisconnect 5 | 6 | 7 | class AsyncDuplex(Protocol): 8 | async def read(self, n: int = -1) -> bytes: ... 9 | async def write(self, data: bytes): ... 10 | async def close(self): ... 11 | 12 | 13 | async def pipe_duplex(a: AsyncDuplex, b: AsyncDuplex, label_a="A", label_b="B"): 14 | """双向管道:a <-> b""" 15 | task_ab = asyncio.create_task(_pipe_oneway(a, b, f"{label_a}->{label_b}")) 16 | task_ba = asyncio.create_task(_pipe_oneway(b, a, f"{label_b}->{label_a}")) 17 | done, pending = await asyncio.wait( 18 | [task_ab, task_ba], 19 | return_when=asyncio.FIRST_COMPLETED, 20 | ) 21 | for t in pending: 22 | t.cancel() 23 | if pending: 24 | await asyncio.gather(*pending, return_exceptions=True) 25 | 26 | 27 | async def _pipe_oneway(src: AsyncDuplex, dst: AsyncDuplex, name: str): 28 | try: 29 | while True: 30 | data = await src.read(4096) 31 | if not data: 32 | break 33 | await dst.write(data) 34 | except asyncio.CancelledError: 35 | pass 36 | except Exception as e: 37 | print(f"[{name}] error:", e) 38 | finally: 39 | await dst.close() 40 | 41 | class RWSocketDuplex: 42 | def __init__(self, rsock: socket.socket, wsock: socket.socket, loop=None): 43 | self.rsock = rsock 44 | self.wsock = wsock 45 | self._same = rsock is wsock 46 | self.loop = loop or asyncio.get_running_loop() 47 | self._closed = False 48 | 49 | self.rsock.setblocking(False) 50 | if not self._same: 51 | self.wsock.setblocking(False) 52 | 53 | async def read(self, n: int = 4096) -> bytes: 54 | if self._closed: 55 | return b'' 56 | try: 57 | data = await self.loop.sock_recv(self.rsock, n) 58 | if not data: 59 | await self.close() 60 | return b'' 61 | return data 62 | except (ConnectionResetError, OSError): 63 | await self.close() 64 | return b'' 65 | 66 | async def write(self, data: bytes): 67 | if not data or self._closed: 68 | return 69 | try: 70 | await self.loop.sock_sendall(self.wsock, data) 71 | except (ConnectionResetError, OSError): 72 | await self.close() 73 | 74 | async def close(self): 75 | if self._closed: 76 | return 77 | self._closed = True 78 | try: 79 | self.rsock.close() 80 | except Exception: 81 | pass 82 | if not self._same: 83 | try: 84 | self.wsock.close() 85 | except Exception: 86 | pass 87 | 88 | def is_closed(self): 89 | return self._closed 90 | 91 | class SocketDuplex(RWSocketDuplex): 92 | """封装 socket.socket 为 AsyncDuplex 接口""" 93 | def __init__(self, sock: socket.socket, loop: Optional[asyncio.AbstractEventLoop] = None): 94 | super().__init__(sock, sock, loop) 95 | 96 | 97 | class WebSocketDuplex: 98 | """将 starlette.websockets.WebSocket 封装为 AsyncDuplex""" 99 | def __init__(self, ws: WebSocket): 100 | self.ws = ws 101 | self._closed = False 102 | 103 | async def read(self, n: int = -1) -> bytes: 104 | """读取二进制消息,如果是文本则自动转 bytes""" 105 | if self._closed: 106 | return b'' 107 | try: 108 | msg = await self.ws.receive() 109 | except WebSocketDisconnect: 110 | self._closed = True 111 | return b'' 112 | except Exception: 113 | self._closed = True 114 | return b'' 115 | 116 | if msg["type"] == "websocket.disconnect": 117 | self._closed = True 118 | return b'' 119 | elif msg["type"] == "websocket.receive": 120 | data = msg.get("bytes") 121 | if data is not None: 122 | return data 123 | text = msg.get("text") 124 | return text.encode("utf-8") if text else b'' 125 | return b'' 126 | 127 | async def write(self, data: bytes): 128 | if self._closed: 129 | return 130 | try: 131 | await self.ws.send_bytes(data) 132 | except Exception: 133 | self._closed = True 134 | 135 | async def close(self): 136 | if not self._closed: 137 | self._closed = True 138 | try: 139 | await self.ws.close() 140 | except Exception: 141 | pass -------------------------------------------------------------------------------- /uiautodev/remote/touch_controller.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import socket 3 | import struct 4 | 5 | from construct import Byte, Int16ub, Int32ub, Int64ub, Struct 6 | 7 | from uiautodev.remote.android_input import KeyeventAction, MetaState 8 | from uiautodev.remote.keycode import KeyCode 9 | 10 | 11 | # https://github.com/Genymobile/scrcpy/blob/master/app/src/control_msg.h#L29 12 | class MessageType(enum.IntEnum): 13 | INJECT_KEYCODE = 0 14 | INJECT_TEXT = 1 15 | INJECT_TOUCH_EVENT = 2 16 | INJECT_SCROLL_EVENT = 3 17 | BACK_OR_SCREEN_ON = 4 18 | EXPAND_NOTIFICATION_PANEL = 5 19 | EXPAND_SETTINGS_PANEL = 6 20 | COLLAPSE_PANELS = 7 21 | GET_CLIPBOARD = 8 22 | SET_CLIPBOARD = 9 23 | SET_DISPLAY_POWER = 10 24 | ROTATE_DEVICE = 11 25 | UHID_CREATE = 12 26 | UHID_INPUT = 13 27 | UHID_DESTROY = 14 28 | OPEN_HARD_KEYBOARD_SETTINGS = 15 29 | START_APP = 16 30 | RESET_VIDEO = 17 31 | 32 | 33 | TouchEvent = Struct( 34 | "type" / Byte, # SC_CONTROL_MSG_TYPE_INJECT_TOUCH_EVENT 35 | "action" / Byte, # AKEY_EVENT_ACTION_DOWN 36 | "pointer_id" / Int64ub, # 8-byte pointer ID 37 | "x" / Int32ub, # X coordinate 38 | "y" / Int32ub, # Y coordinate 39 | "width" / Int16ub, # width 40 | "height" / Int16ub, # height 41 | "pressure" / Int16ub, # pressure 42 | "action_button" / Int32ub, # action button 43 | "buttons" / Int32ub # buttons 44 | ) 45 | 46 | 47 | # Define the structure for key events 48 | KeyEvent = Struct( 49 | "type" / Byte, # SC_CONTROL_MSG_TYPE_INJECT_KEYCODE 50 | "action" / Byte, # AKEY_EVENT_ACTION (DOWN, UP, MULTIPLE) 51 | "keycode" / Int32ub, # Android keycode 52 | "repeat" / Int32ub, # Repeat count 53 | "metastate" / Int32ub # Meta state flags (SHIFT, ALT, etc.) 54 | ) 55 | 56 | 57 | class ScrcpyTouchController: 58 | """scrcpy控制类,支持scrcpy版本>=2.2""" 59 | 60 | def __init__(self, control_socket: socket.socket): 61 | self.control_socket = control_socket 62 | 63 | def _build_touch_event(self, action: int, x: int, y: int, width: int, height: int): 64 | x = max(0, min(x, width)) 65 | y = max(0, min(y, height)) 66 | return TouchEvent.build(dict( 67 | type=MessageType.INJECT_TOUCH_EVENT, 68 | action=action, 69 | pointer_id=1, 70 | x=x, 71 | y=y, 72 | width=width, 73 | height=height, 74 | pressure=1, 75 | action_button=1, # AMOTION_EVENT_BUTTON_PRIMARY (action button) 76 | buttons=1, # AMOTION_EVENT_BUTTON_PRIMARY (buttons) 77 | )) 78 | 79 | def down(self, x: int, y: int, width: int, height: int): 80 | """发送down操作""" 81 | data = self._build_touch_event(0, x, y, width, height) 82 | self.control_socket.send(data) 83 | 84 | def up(self, x: int, y: int, width: int, height: int): 85 | """发送up操作""" 86 | data = self._build_touch_event(1, x, y, width, height) 87 | self.control_socket.send(data) 88 | 89 | def move(self, x: int, y: int, width: int, height: int): 90 | """发送move操作""" 91 | data = self._build_touch_event(2, x, y, width, height) 92 | self.control_socket.send(data) 93 | 94 | def text(self, text: str): 95 | """发送文本操作""" 96 | 97 | # buffer = text.encode("utf-8") 98 | # values = struct.pack(self.format_string, 2, 3, 1, len(buffer), 0, 0, 0, self.const_value, 99 | # self.unknown1, self.unknown2) + buffer 100 | # self.control_socket.send(values) 101 | pass 102 | 103 | def key(self, action: KeyeventAction, keycode: KeyCode, repeat: int, metastate: MetaState): 104 | """ 105 | Send a keycode event to the Android device 106 | 107 | Args: 108 | action: Key action (DOWN, UP, or MULTIPLE) 109 | keycode: Android key code to send 110 | repeat: Number of times the key is repeated 111 | metastate: Meta state flags (SHIFT, ALT, etc.) 112 | """ 113 | # Build the data using the KeyEvent structure 114 | data = KeyEvent.build(dict( 115 | type=MessageType.INJECT_KEYCODE, # Type byte 116 | action=action, # Action byte 117 | keycode=keycode, # Keycode (4 bytes) 118 | repeat=repeat, # Repeat count (4 bytes) 119 | metastate=metastate, # Meta state (4 bytes) 120 | )) 121 | 122 | # Send the data to the control socket 123 | self.control_socket.send(data) 124 | -------------------------------------------------------------------------------- /uiautodev/router/device.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Fri Mar 01 2024 14:00:10 by codeskyblue 5 | """ 6 | 7 | import io 8 | import logging 9 | from typing import Any, Dict, List, Optional 10 | 11 | from fastapi import APIRouter, Query, Request, Response 12 | from fastapi.responses import StreamingResponse 13 | from pydantic import BaseModel 14 | 15 | from uiautodev import command_proxy 16 | from uiautodev.command_types import Command, CurrentAppResponse, InstallAppRequest, InstallAppResponse, TapRequest 17 | from uiautodev.model import DeviceInfo, Node, ShellResponse 18 | from uiautodev.provider import BaseProvider 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | def make_router(provider: BaseProvider) -> APIRouter: 24 | router = APIRouter() 25 | 26 | @router.get("/list") 27 | def _list() -> List[DeviceInfo]: 28 | """List devices""" 29 | try: 30 | return provider.list_devices() 31 | except NotImplementedError as e: 32 | return Response(content="list_devices not implemented", media_type="text/plain", status_code=501) 33 | except Exception as e: 34 | logger.exception("list_devices failed") 35 | return Response(content=str(e), media_type="text/plain", status_code=500) 36 | 37 | @router.get( 38 | "/{serial}/screenshot/{id}", 39 | responses={200: {"content": {"image/jpeg": {}}}}, 40 | response_class=Response, 41 | ) 42 | def _screenshot(serial: str, id: int) -> Response: 43 | """Take a screenshot of device""" 44 | try: 45 | driver = provider.get_device_driver(serial) 46 | pil_img = driver.screenshot(id).convert("RGB") 47 | buf = io.BytesIO() 48 | pil_img.save(buf, format="JPEG") 49 | image_bytes = buf.getvalue() 50 | return Response(content=image_bytes, media_type="image/jpeg") 51 | except Exception as e: 52 | logger.exception("screenshot failed") 53 | return Response(content=str(e), media_type="text/plain", status_code=500) 54 | 55 | @router.get("/{serial}/hierarchy") 56 | def dump_hierarchy(serial: str, format: str = "json"): 57 | """Dump the view hierarchy of an Android device""" 58 | try: 59 | driver = provider.get_device_driver(serial) 60 | xml_data, hierarchy = driver.dump_hierarchy() 61 | if format == "xml": 62 | return Response(content=xml_data, media_type="text/xml") 63 | elif format == "json": 64 | return hierarchy 65 | else: 66 | return Response(content=f"Invalid format: {format}", media_type="text/plain", status_code=400) 67 | except Exception as e: 68 | #logger.exception("dump_hierarchy failed") 69 | logger.error(f"Error dumping hierarchy: {str(e)}") 70 | return Response(content=str(e), media_type="text/plain", status_code=500) 71 | 72 | @router.post('/{serial}/command/tap') 73 | def command_tap(serial: str, params: TapRequest): 74 | """Run a command on the device""" 75 | driver = provider.get_device_driver(serial) 76 | command_proxy.tap(driver, params) 77 | return {"status": "ok"} 78 | 79 | @router.post('/{serial}/command/installApp') 80 | def install_app(serial: str, params: InstallAppRequest) -> InstallAppResponse: 81 | """Install app""" 82 | driver = provider.get_device_driver(serial) 83 | return command_proxy.app_install(driver, params) 84 | 85 | @router.get('/{serial}/command/currentApp') 86 | def current_app(serial: str) -> CurrentAppResponse: 87 | """Get current app""" 88 | driver = provider.get_device_driver(serial) 89 | return command_proxy.app_current(driver) 90 | 91 | @router.post('/{serial}/command/{command}') 92 | def _command_proxy_other(serial: str, command: Command, params: Dict[str, Any] = None): 93 | """Run a command on the device""" 94 | driver = provider.get_device_driver(serial) 95 | response = command_proxy.send_command(driver, command, params) 96 | return response 97 | 98 | @router.get('/{serial}/backupApp') 99 | def _backup_app(serial: str, packageName: str): 100 | """Backup app 101 | 102 | Added in 0.5.0 103 | """ 104 | driver = provider.get_device_driver(serial) 105 | file_name = f"{packageName}.apk" 106 | headers = { 107 | 'Content-Disposition': f'attachment; filename="{file_name}"' 108 | } 109 | return StreamingResponse(driver.open_app_file(packageName), headers=headers) 110 | 111 | 112 | 113 | return router 114 | -------------------------------------------------------------------------------- /uiautodev/utils/common.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import json as sysjson 5 | import platform 6 | import re 7 | import socket 8 | import subprocess 9 | import sys 10 | import typing 11 | import uuid 12 | from http.client import HTTPConnection, HTTPResponse 13 | from typing import List, Optional, TypeVar, Union 14 | 15 | from pydantic import BaseModel 16 | from pygments import formatters, highlight, lexers 17 | 18 | from uiautodev.exceptions import RequestError 19 | from uiautodev.model import Node 20 | 21 | 22 | def is_output_terminal() -> bool: 23 | """ 24 | Check if the standard output is attached to a terminal. 25 | """ 26 | return sys.stdout.isatty() 27 | 28 | 29 | def enable_windows_ansi_support(): 30 | if platform.system().lower() == "windows" and is_output_terminal(): 31 | import ctypes 32 | 33 | kernel32 = ctypes.windll.kernel32 34 | kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) 35 | 36 | 37 | def default_json_encoder(obj): 38 | if isinstance(obj, bytes): 39 | return f'<{obj.hex()}>' 40 | if isinstance(obj, datetime.datetime): 41 | return str(obj) 42 | if isinstance(obj, uuid.UUID): 43 | return str(obj) 44 | if isinstance(obj, BaseModel): 45 | return obj.model_dump() 46 | raise TypeError() 47 | 48 | 49 | def print_json(buf, colored=None, default=default_json_encoder): 50 | """ copy from pymobiledevice3 """ 51 | formatted_json = sysjson.dumps(buf, sort_keys=True, indent=4, default=default) 52 | if colored is None: 53 | if is_output_terminal(): 54 | colored = True 55 | enable_windows_ansi_support() 56 | else: 57 | colored = False 58 | 59 | if colored: 60 | colorful_json = highlight(formatted_json, lexers.JsonLexer(), 61 | formatters.TerminalTrueColorFormatter(style='stata-dark')) 62 | print(colorful_json) 63 | else: 64 | print(formatted_json) 65 | 66 | 67 | _T = TypeVar("_T") 68 | 69 | 70 | def convert_to_type(value: str, _type: _T) -> _T: 71 | """ usage example: 72 | convert_to_type("123", int) 73 | """ 74 | if _type in (int, float, str): 75 | return _type(value) 76 | if _type == bool: 77 | return value.lower() in ("true", "1") 78 | if _type == Union[int, float]: 79 | return float(value) if "." in value else int(value) 80 | if _type == re.Pattern: 81 | return re.compile(value) 82 | raise NotImplementedError(f"convert {value} to {_type}") 83 | 84 | 85 | def convert_params_to_model(params: List[str], model: BaseModel) -> BaseModel: 86 | """ used in cli.py """ 87 | assert len(params) > 0 88 | if len(params) == 1: 89 | try: 90 | return model.model_validate_json(params) 91 | except Exception as e: 92 | print("module_parse_error", e) 93 | 94 | value = {} 95 | type_hints = typing.get_type_hints(model) 96 | for p in params: 97 | if "=" not in p: 98 | _type = type_hints.get(p) 99 | if _type == bool: 100 | value[p] = True 101 | continue 102 | elif _type is None: 103 | print(f"unknown key: {p}") 104 | continue 105 | raise ValueError(f"missing value for {p}") 106 | k, v = p.split("=", 1) 107 | _type = type_hints.get(k) 108 | if _type is None: 109 | print(f"unknown key: {k}") 110 | continue 111 | value[k] = convert_to_type(v, _type) 112 | return model.model_validate(value) 113 | 114 | 115 | class SocketHTTPConnection(HTTPConnection): 116 | def __init__(self, conn: socket.socket, timeout: float): 117 | super().__init__("localhost", timeout=timeout) 118 | self.__conn = conn 119 | 120 | def connect(self): 121 | self.sock = self.__conn 122 | 123 | def __enter__(self) -> HTTPConnection: 124 | return self 125 | 126 | def __exit__(self, exc_type, exc_value, traceback): 127 | self.close() 128 | 129 | 130 | class MySocketHTTPConnection(SocketHTTPConnection): 131 | def connect(self): 132 | super().connect() 133 | self.sock.settimeout(self.timeout) 134 | 135 | 136 | def fetch_through_socket(sock: socket.socket, path: str, method: str = "GET", json: Optional[dict] = None, 137 | timeout: float = 60) -> bytearray: 138 | """ usage example: 139 | with socket.create_connection((host, port)) as s: 140 | request_through_socket(s, "GET", "/") 141 | """ 142 | conn = MySocketHTTPConnection(sock, timeout) 143 | try: 144 | if json is None: 145 | conn.request(method, path) 146 | else: 147 | conn.request(method, path, body=sysjson.dumps(json), headers={"Content-Type": "application/json"}) 148 | response = conn.getresponse() 149 | if response.getcode() != 200: 150 | raise RequestError(f"request {method} {path}, status: {response.getcode()}") 151 | content = bytearray() 152 | while chunk := response.read(40960): 153 | content.extend(chunk) 154 | return content 155 | finally: 156 | conn.close() 157 | 158 | 159 | def node_travel(node: Node, dfs: bool = True): 160 | """ usage example: 161 | for n in node_travel(node): 162 | print(n) 163 | """ 164 | if not dfs: 165 | yield node 166 | for child in node.children: 167 | yield from node_travel(child, dfs) 168 | if dfs: 169 | yield node 170 | 171 | -------------------------------------------------------------------------------- /uiautodev/driver/appium.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Tue Mar 19 2024 15:51:59 by codeskyblue 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import io 10 | import json 11 | import logging 12 | from pprint import pprint 13 | from typing import Tuple 14 | 15 | import httpretty 16 | import httpx 17 | from appium import webdriver 18 | from appium.options.android import UiAutomator2Options 19 | from appium.options.ios import XCUITestOptions 20 | from appium.webdriver.common.appiumby import AppiumBy as By 21 | from PIL import Image 22 | from selenium.webdriver.common.proxy import Proxy, ProxyType 23 | 24 | from uiautodev.command_types import CurrentAppResponse 25 | from uiautodev.driver.android import parse_xml 26 | from uiautodev.driver.base_driver import BaseDriver 27 | from uiautodev.exceptions import AppiumDriverException 28 | from uiautodev.model import DeviceInfo, Node, ShellResponse, WindowSize 29 | from uiautodev.provider import BaseProvider 30 | 31 | logger = logging.getLogger(__name__) 32 | 33 | class AppiumProvider(BaseProvider): 34 | sessions = [] 35 | 36 | def __init__(self, command_executor: str = "http://localhost:4723/wd/hub"): 37 | # command_executor = "http://localhost:4700" 38 | # command_executor = "http://localhost:4720/wd/hub" 39 | self.command_executor = command_executor.rstrip('/') 40 | self.sessions.clear() 41 | 42 | def list_devices(self) -> list[DeviceInfo]: 43 | """ appium just return all session_ids """ 44 | response = httpx.get(f"{self.command_executor}/sessions", verify=False) 45 | if response.status_code >= 400: 46 | raise AppiumDriverException(f"Failed request to appium server: {self.command_executor} status: {response.status_code}") 47 | ret = [] 48 | self.sessions = response.json()['value'] 49 | for item in self.sessions: 50 | item['sessionId'] = item.pop('id') 51 | print("Active sessionId", item['sessionId']) 52 | serial = item['capabilities']['platformName'] + ':' + item['sessionId'] 53 | ret.append(DeviceInfo( 54 | serial=serial, 55 | model=item['capabilities']['deviceModel'], 56 | name=item['capabilities']['deviceName'], 57 | )) 58 | return ret 59 | 60 | def get_device_driver(self, serial: str, session_id: str = None) -> BaseDriver: 61 | """ TODO: attach to the existing session """ 62 | platform_name, session_id = serial.split(':', 1) 63 | filtered_sessions = [session for session in self.sessions if session['sessionId'] == session_id] 64 | if len(filtered_sessions) == 1: 65 | session = filtered_sessions[0] 66 | driver = self.attach_session(session) 67 | return AppiumDriver(driver, is_attached=True) 68 | else: 69 | options = UiAutomator2Options() if platform_name == "Android" else XCUITestOptions() 70 | driver = webdriver.Remote(self.command_executor, options=options) 71 | return AppiumDriver(driver) 72 | 73 | @httpretty.activate(allow_net_connect=False) 74 | def attach_session(self, session: dict) -> webdriver.Remote: 75 | """ 76 | https://github.com/appium/python-client/issues/212 77 | the author say it can't 78 | """ 79 | body = json.dumps({'value': session}, indent=4) 80 | logger.debug("Mock response: POST /wd/hub/session", body) 81 | httpretty.register_uri(httpretty.POST, 82 | self.command_executor + '/session', 83 | body=body, 84 | headers={'Content-Type': 'application/json'}) 85 | options = UiAutomator2Options()# if platform_name == "Android" else XCUITestOptions() 86 | driver = webdriver.Remote(command_executor=self.command_executor, strict_ssl=False, options=options) 87 | return driver 88 | 89 | def get_single_device_driver(self) -> BaseDriver: 90 | devices = self.list_devices() 91 | if len(devices) == 0: 92 | return self.get_device_driver("Android:12345") 93 | # raise AppiumDriverException("No device found") 94 | return self.get_device_driver(devices[0].serial) 95 | 96 | 97 | class AppiumDriver(BaseDriver): 98 | def __init__(self, driver: webdriver.Remote, is_attached: bool = False): 99 | self.driver = driver 100 | self.is_attached = is_attached 101 | 102 | # def __del__(self): 103 | # if not self.is_attached: 104 | # self.driver.quit() 105 | 106 | def screenshot(self, id: int) -> Image: 107 | png_data = self.driver.get_screenshot_as_png() 108 | return Image.open(io.BytesIO(png_data)) 109 | 110 | def window_size(self) -> WindowSize: 111 | size = self.driver.get_window_size() 112 | return WindowSize(width=size["width"], height=size["height"]) 113 | 114 | def dump_hierarchy(self) -> Tuple[str, Node]: 115 | source = self.driver.page_source 116 | wsize = self.window_size() 117 | return source, parse_xml(source, wsize) 118 | 119 | def shell(self, command: str) -> ShellResponse: 120 | # self.driver.execute_script(command) 121 | raise NotImplementedError() 122 | 123 | def tap(self, x: int, y: int): 124 | self.driver.tap([(x, y)], 100) 125 | print("Finished") 126 | 127 | def app_install(self, app_path: str): 128 | self.driver.install_app(app_path) 129 | 130 | def app_current(self) -> CurrentAppResponse: 131 | package = self.driver.current_package 132 | activity = self.driver.current_activity 133 | return CurrentAppResponse(package=package, activity=activity) 134 | 135 | def home(self): 136 | self.driver.press_keycode(3) -------------------------------------------------------------------------------- /uiautodev/command_proxy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Tue Mar 19 2024 10:43:51 by codeskyblue 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import time 10 | import typing 11 | from typing import Callable, Dict, List, Optional, Union 12 | 13 | from pydantic import BaseModel 14 | 15 | from uiautodev.command_types import AppLaunchRequest, AppTerminateRequest, By, Command, CurrentAppResponse, \ 16 | DumpResponse, FindElementRequest, FindElementResponse, InstallAppRequest, InstallAppResponse, SendKeysRequest, \ 17 | TapRequest, WindowSizeResponse 18 | from uiautodev.driver.base_driver import BaseDriver 19 | from uiautodev.exceptions import ElementNotFoundError 20 | from uiautodev.model import AppInfo, Node 21 | from uiautodev.utils.common import node_travel 22 | 23 | COMMANDS: Dict[Command, Callable] = {} 24 | 25 | 26 | def register(command: Command): 27 | def wrapper(func): 28 | COMMANDS[command] = func 29 | return func 30 | 31 | return wrapper 32 | 33 | 34 | def get_command_params_type(command: Command) -> Optional[BaseModel]: 35 | func = COMMANDS.get(command) 36 | if func is None: 37 | return None 38 | type_hints = typing.get_type_hints(func) 39 | return type_hints.get("params") 40 | 41 | 42 | def send_command(driver: BaseDriver, command: Command, params=None): 43 | if command not in COMMANDS: 44 | raise NotImplementedError(f"command {command} not implemented") 45 | func = COMMANDS[command] 46 | params_model = get_command_params_type(command) 47 | if params_model: 48 | if params is None: 49 | raise ValueError(f"params is required for {command}") 50 | if isinstance(params, dict): 51 | params = params_model.model_validate(params) 52 | elif isinstance(params, params_model): 53 | pass 54 | else: 55 | raise TypeError(f"params should be {params_model}", params) 56 | if not params: 57 | return func(driver) 58 | return func(driver, params) 59 | 60 | 61 | @register(Command.TAP) 62 | def tap(driver: BaseDriver, params: TapRequest): 63 | """Tap on the screen 64 | """ 65 | x = params.x 66 | y = params.y 67 | if params.isPercent: 68 | wsize = driver.window_size() 69 | x = int(wsize[0] * params.x) 70 | y = int(wsize[1] * params.y) 71 | driver.tap(int(x), int(y)) 72 | 73 | 74 | @register(Command.APP_INSTALL) 75 | def app_install(driver: BaseDriver, params: InstallAppRequest): 76 | """install app""" 77 | driver.app_install(params.url) 78 | return InstallAppResponse(success=True, id=None) 79 | 80 | 81 | @register(Command.APP_CURRENT) 82 | def app_current(driver: BaseDriver) -> CurrentAppResponse: 83 | """get current app""" 84 | return driver.app_current() 85 | 86 | 87 | @register(Command.APP_LAUNCH) 88 | def app_launch(driver: BaseDriver, params: AppLaunchRequest): 89 | if params.stop: 90 | driver.app_terminate(params.package) 91 | driver.app_launch(params.package) 92 | 93 | 94 | @register(Command.APP_TERMINATE) 95 | def app_terminate(driver: BaseDriver, params: AppTerminateRequest): 96 | driver.app_terminate(params.package) 97 | 98 | 99 | @register(Command.GET_WINDOW_SIZE) 100 | def window_size(driver: BaseDriver) -> WindowSizeResponse: 101 | wsize = driver.window_size() 102 | return WindowSizeResponse(width=wsize[0], height=wsize[1]) 103 | 104 | 105 | @register(Command.HOME) 106 | def home(driver: BaseDriver): 107 | driver.home() 108 | 109 | 110 | @register(Command.BACK) 111 | def back(driver: BaseDriver): 112 | driver.back() 113 | 114 | 115 | @register(Command.APP_SWITCH) 116 | def app_switch(driver: BaseDriver): 117 | driver.app_switch() 118 | 119 | 120 | @register(Command.VOLUME_UP) 121 | def volume_up(driver: BaseDriver): 122 | driver.volume_up() 123 | 124 | 125 | @register(Command.VOLUME_DOWN) 126 | def volume_down(driver: BaseDriver): 127 | driver.volume_down() 128 | 129 | 130 | @register(Command.VOLUME_MUTE) 131 | def volume_mute(driver: BaseDriver): 132 | driver.volume_mute() 133 | 134 | 135 | @register(Command.DUMP) 136 | def dump(driver: BaseDriver) -> DumpResponse: 137 | source, _ = driver.dump_hierarchy() 138 | return DumpResponse(value=source) 139 | 140 | 141 | @register(Command.WAKE_UP) 142 | def wake_up(driver: BaseDriver): 143 | driver.wake_up() 144 | 145 | @register(Command.SEND_KEYS) 146 | def send_keys(driver: BaseDriver, params: SendKeysRequest): 147 | driver.send_keys(params.text) 148 | 149 | @register(Command.CLEAR_TEXT) 150 | def clear_text(driver: BaseDriver): 151 | driver.clear_text() 152 | 153 | 154 | def node_match(node: Node, by: By, value: str) -> bool: 155 | if by == By.ID: 156 | return node.properties.get("resource-id") == value 157 | if by == By.TEXT: 158 | return node.properties.get("text") == value 159 | if by == By.CLASS_NAME: 160 | return node.name == value 161 | raise ValueError(f"not support by {by!r}") 162 | 163 | 164 | @register(Command.FIND_ELEMENTS) 165 | def find_elements(driver: BaseDriver, params: FindElementRequest) -> FindElementResponse: 166 | _, root_node = driver.dump_hierarchy() 167 | # TODO: support By.XPATH 168 | nodes = [] 169 | for node in node_travel(root_node): 170 | if node_match(node, params.by, params.value): 171 | nodes.append(node) 172 | return FindElementResponse(count=len(nodes), value=nodes) 173 | 174 | 175 | @register(Command.CLICK_ELEMENT) 176 | def click_element(driver: BaseDriver, params: FindElementRequest): 177 | node = None 178 | deadline = time.time() + params.timeout 179 | while time.time() < deadline: 180 | result = find_elements(driver, params) 181 | if result.value: 182 | node = result.value[0] 183 | break 184 | time.sleep(.5) # interval 185 | if not node: 186 | raise ElementNotFoundError(f"element not found by {params.by}={params.value}") 187 | center_x = (node.bounds[0] + node.bounds[2]) / 2 188 | center_y = (node.bounds[1] + node.bounds[3]) / 2 189 | tap(driver, TapRequest(x=center_x, y=center_y, isPercent=True)) 190 | 191 | 192 | @register(Command.APP_LIST) 193 | def app_list(driver: BaseDriver) -> List[AppInfo]: 194 | # added in v0.5.0 195 | return driver.app_list() 196 | 197 | -------------------------------------------------------------------------------- /uiautodev/router/proxy.py: -------------------------------------------------------------------------------- 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)) -------------------------------------------------------------------------------- /examples/harmony-video.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | WebSocket Video Stream 7 | 30 | 31 | 32 |
33 | 34 |
35 | 36 | 186 | 187 | 188 | -------------------------------------------------------------------------------- /uiautodev/driver/android/adb_driver.py: -------------------------------------------------------------------------------- 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 typing import Iterator, List, Optional, Tuple 11 | 12 | import adbutils 13 | from PIL import Image 14 | 15 | from uiautodev.command_types import CurrentAppResponse 16 | from uiautodev.driver.android.common import parse_xml 17 | from uiautodev.driver.base_driver import BaseDriver 18 | from uiautodev.exceptions import AndroidDriverException 19 | from uiautodev.model import AppInfo, Node, Rect, ShellResponse, WindowSize 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | class ADBAndroidDriver(BaseDriver): 24 | def __init__(self, serial: str): 25 | super().__init__(serial) 26 | self.adb_device = adbutils.device(serial) 27 | 28 | def get_current_activity(self) -> str: 29 | ret = self.adb_device.shell2(["dumpsys", "activity", "activities"], rstrip=True, timeout=5) 30 | # 使用正则查找包含前台 activity 的行 31 | match = re.search(r"mResumedActivity:.*? ([\w\.]+\/[\w\.]+)", ret.output) 32 | if match: 33 | return match.group(1) # 返回包名/类名,例如 com.example/.MainActivity 34 | else: 35 | return "" 36 | 37 | def screenshot(self, id: int) -> Image.Image: 38 | if id > 0: 39 | raise AndroidDriverException("multi-display is not supported yet for uiautomator2") 40 | return self.adb_device.screenshot(display_id=id) 41 | 42 | def shell(self, command: str) -> ShellResponse: 43 | try: 44 | ret = self.adb_device.shell2(command, rstrip=True, timeout=20) 45 | if ret.returncode == 0: 46 | return ShellResponse(output=ret.output, error=None) 47 | else: 48 | return ShellResponse( 49 | output="", error=f"exit:{ret.returncode}, output:{ret.output}" 50 | ) 51 | except Exception as e: 52 | return ShellResponse(output="", error=f"adb error: {str(e)}") 53 | 54 | def dump_hierarchy(self, display_id: Optional[int] = 0) -> Tuple[str, Node]: 55 | """returns xml string and hierarchy object""" 56 | start = time.time() 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)}") 62 | 63 | wsize = self.adb_device.window_size() 64 | logger.debug("window size: %s", wsize) 65 | return xml_data, parse_xml( 66 | xml_data, WindowSize(width=wsize[0], height=wsize[1]), display_id 67 | ) 68 | 69 | def _dump_hierarchy_raw(self) -> str: 70 | """ 71 | uiautomator2 server is conflict with "uiautomator dump" command. 72 | 73 | uiautomator dump errors: 74 | - ERROR: could not get idle state. 75 | """ 76 | try: 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() 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 | 95 | def tap(self, x: int, y: int): 96 | self.adb_device.click(x, y) 97 | 98 | def window_size(self) -> Tuple[int, int]: 99 | w, h = self.adb_device.window_size() 100 | return (w, h) 101 | 102 | def app_install(self, app_path: str): 103 | self.adb_device.install(app_path) 104 | 105 | def app_current(self) -> CurrentAppResponse: 106 | info = self.adb_device.app_current() 107 | return CurrentAppResponse( 108 | package=info.package, activity=info.activity, pid=info.pid 109 | ) 110 | 111 | def app_launch(self, package: str): 112 | if self.adb_device.package_info(package) is None: 113 | raise AndroidDriverException(f"App not installed: {package}") 114 | self.adb_device.app_start(package) 115 | 116 | def app_terminate(self, package: str): 117 | self.adb_device.app_stop(package) 118 | 119 | def home(self): 120 | self.adb_device.keyevent("HOME") 121 | 122 | def wake_up(self): 123 | self.adb_device.keyevent("WAKEUP") 124 | 125 | def back(self): 126 | self.adb_device.keyevent("BACK") 127 | 128 | def app_switch(self): 129 | self.adb_device.keyevent("APP_SWITCH") 130 | 131 | def volume_up(self): 132 | self.adb_device.keyevent("VOLUME_UP") 133 | 134 | def volume_down(self): 135 | self.adb_device.keyevent("VOLUME_DOWN") 136 | 137 | def volume_mute(self): 138 | self.adb_device.keyevent("VOLUME_MUTE") 139 | 140 | def get_app_version(self, package_name: str) -> dict: 141 | """ 142 | Get the version information of an app, including mainVersion and subVersion. 143 | 144 | Args: 145 | package_name (str): The package name of the app. 146 | 147 | Returns: 148 | dict: A dictionary containing mainVersion and subVersion. 149 | """ 150 | output = self.adb_device.shell(["dumpsys", "package", package_name]) 151 | 152 | # versionName 153 | m = re.search(r"versionName=(?P[^\s]+)", output) 154 | version_name = m.group("name") if m else "" 155 | if version_name == "null": # Java dumps "null" for null values 156 | version_name = None 157 | 158 | # versionCode 159 | m = re.search(r"versionCode=(?P\d+)", output) 160 | version_code = m.group("code") if m else "" 161 | version_code = int(version_code) if version_code.isdigit() else None 162 | 163 | return { 164 | "versionName": version_name, 165 | "versionCode": version_code 166 | } 167 | 168 | def app_list(self) -> List[AppInfo]: 169 | results = [] 170 | output = self.adb_device.shell(["pm", "list", "packages", '-3']) 171 | for m in re.finditer(r"^package:([^\s]+)\r?$", output, re.M): 172 | packageName = m.group(1) 173 | # get version 174 | version_info = self.get_app_version(packageName) 175 | app_info = AppInfo( 176 | packageName=packageName, 177 | versionName=version_info.get("versionName"), 178 | versionCode=version_info.get("versionCode") 179 | ) 180 | results.append(app_info) 181 | return results 182 | 183 | def open_app_file(self, package: str) -> Iterator[bytes]: 184 | line = self.adb_device.shell(f"pm path {package}") 185 | assert isinstance(line, str) 186 | if not line.startswith("package:"): 187 | raise AndroidDriverException(f"Failed to get package path: {line}") 188 | remote_path = line.split(':', 1)[1] 189 | yield from self.adb_device.sync.iter_content(remote_path) 190 | 191 | def send_keys(self, text: str): 192 | self.adb_device.send_keys(text) 193 | 194 | def clear_text(self): 195 | for _ in range(3): 196 | self.adb_device.shell2("input keyevent DEL --longpress") 197 | -------------------------------------------------------------------------------- /uiautodev/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Tue Mar 19 2024 10:53:03 by codeskyblue 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import logging 10 | import os 11 | import platform 12 | import subprocess 13 | import sys 14 | import threading 15 | import time 16 | from pprint import pprint 17 | 18 | import click 19 | import httpx 20 | import pydantic 21 | import uvicorn 22 | from retry import retry 23 | from rich.logging import RichHandler 24 | 25 | from uiautodev import __version__, command_proxy 26 | from uiautodev.command_types import Command 27 | from uiautodev.common import get_webpage_url 28 | from uiautodev.provider import AndroidProvider, BaseProvider, IOSProvider 29 | from uiautodev.utils.common import convert_params_to_model, print_json 30 | 31 | logger = logging.getLogger(__name__) 32 | 33 | CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) 34 | HARMONY_PACKAGES = [ 35 | "setuptools", 36 | "https://public.uiauto.devsleep.com/harmony/xdevice-5.0.7.200.tar.gz", 37 | "https://public.uiauto.devsleep.com/harmony/xdevice-devicetest-5.0.7.200.tar.gz", 38 | "https://public.uiauto.devsleep.com/harmony/xdevice-ohos-5.0.7.200.tar.gz", 39 | "https://public.uiauto.devsleep.com/harmony/hypium-5.0.7.200.tar.gz", 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 | 49 | @click.group(context_settings=CONTEXT_SETTINGS) 50 | @click.option("--verbose", "-v", is_flag=True, default=False, help="verbose mode") 51 | def cli(verbose: bool): 52 | if verbose: 53 | enable_logger_to_console(level=logging.DEBUG) 54 | logger.debug("Verbose mode enabled") 55 | else: 56 | enable_logger_to_console(level=logging.INFO) 57 | 58 | 59 | def run_driver_command(provider: BaseProvider, command: Command, params: list[str] = None): 60 | if command == Command.LIST: 61 | devices = provider.list_devices() 62 | print("==> Devices <==") 63 | pprint(devices) 64 | return 65 | driver = provider.get_single_device_driver() 66 | params_obj = None 67 | model = command_proxy.get_command_params_type(command) 68 | if model: 69 | if not params: 70 | print(f"params is required for {command}") 71 | pprint(model.model_json_schema()) 72 | return 73 | params_obj = convert_params_to_model(params, model) 74 | 75 | try: 76 | print("Command:", command.value) 77 | print("Params ↓") 78 | print_json(params_obj) 79 | result = command_proxy.send_command(driver, command, params_obj) 80 | print("Result ↓") 81 | print_json(result) 82 | except pydantic.ValidationError as e: 83 | print(f"params error: {e}") 84 | print(f"\n--- params should be match schema ---") 85 | pprint(model.model_json_schema()["properties"]) 86 | 87 | 88 | @cli.command(help="COMMAND: " + ", ".join(c.value for c in Command)) 89 | @click.argument("command", type=Command, required=True) 90 | @click.argument("params", required=False, nargs=-1) 91 | def android(command: Command, params: list[str] = None): 92 | provider = AndroidProvider() 93 | run_driver_command(provider, command, params) 94 | 95 | 96 | @cli.command(help="COMMAND: " + ", ".join(c.value for c in Command)) 97 | @click.argument("command", type=Command, required=True) 98 | @click.argument("params", required=False, nargs=-1) 99 | def ios(command: Command, params: list[str] = None): 100 | provider = IOSProvider() 101 | run_driver_command(provider, command, params) 102 | 103 | 104 | @cli.command(help="run case (beta)") 105 | def case(): 106 | from uiautodev.case import run 107 | run() 108 | 109 | 110 | @cli.command(help="COMMAND: " + ", ".join(c.value for c in Command)) 111 | @click.argument("command", type=Command, required=True) 112 | @click.argument("params", required=False, nargs=-1) 113 | def appium(command: Command, params: list[str] = None): 114 | from uiautodev.driver.appium import AppiumProvider 115 | from uiautodev.exceptions import AppiumDriverException 116 | 117 | provider = AppiumProvider() 118 | try: 119 | run_driver_command(provider, command, params) 120 | except AppiumDriverException as e: 121 | print(f"Error: {e}") 122 | 123 | 124 | @cli.command('version') 125 | def print_version(): 126 | """ Print version """ 127 | print(__version__) 128 | 129 | 130 | @cli.command('self-update') 131 | def self_update(): 132 | """ Update uiautodev to latest version """ 133 | subprocess.run([sys.executable, '-m', "pip", "install", "--upgrade", "uiautodev"]) 134 | 135 | 136 | @cli.command('install-harmony') 137 | def install_harmony(): 138 | pip_install("hypium") 139 | 140 | @retry(tries=2, delay=3, backoff=2) 141 | def pip_install(package: str): 142 | """Install a package using pip.""" 143 | subprocess.run([sys.executable, '-m', "pip", "install", package], check=True) 144 | click.echo(f"Successfully installed {package}") 145 | 146 | 147 | @cli.command(help="start uiauto.dev local server [Default]") 148 | @click.option("--port", default=20242, help="port number", show_default=True) 149 | @click.option("--host", default="127.0.0.1", help="host", show_default=True) 150 | @click.option("--reload", is_flag=True, default=False, help="auto reload, dev only") 151 | @click.option("-f", "--force", is_flag=True, default=False, help="shutdown already running server") 152 | @click.option("-s", "--no-browser", is_flag=True, default=False, help="silent mode, do not open browser") 153 | @click.option("--offline", is_flag=True, default=False, help="offline mode, do not use internet") 154 | @click.option("--server-url", default="https://uiauto.dev", help="uiauto.dev server url", show_default=True) 155 | def server(port: int, host: str, reload: bool, force: bool, no_browser: bool, offline: bool, server_url: str): 156 | click.echo(f"uiautodev version: {__version__}") 157 | if force: 158 | try: 159 | httpx.get(f"http://{host}:{port}/shutdown", timeout=3) 160 | except httpx.HTTPError: 161 | pass 162 | 163 | use_color = True 164 | if platform.system() == 'Windows': 165 | use_color = False 166 | 167 | server_url = server_url.rstrip('/') 168 | from uiautodev.router import proxy 169 | proxy.base_url = server_url 170 | 171 | if offline: 172 | proxy.cache_dir.mkdir(parents=True, exist_ok=True) 173 | logger.info("offline mode enabled, cache dir: %s, server url: %s", proxy.cache_dir, proxy.base_url) 174 | 175 | if not no_browser: 176 | th = threading.Thread(target=open_browser_when_server_start, args=(f"http://{host}:{port}", offline)) 177 | th.daemon = True 178 | th.start() 179 | uvicorn.run("uiautodev.app:app", host=host, port=port, reload=reload, use_colors=use_color) 180 | 181 | @cli.command(help="shutdown uiauto.dev local server") 182 | @click.option("--port", default=20242, help="port number", show_default=True) 183 | def shutdown(port: int): 184 | try: 185 | httpx.get(f"http://127.0.0.1:{port}/shutdown", timeout=3) 186 | except httpx.HTTPError: 187 | pass 188 | 189 | 190 | def open_browser_when_server_start(local_server_url: str, offline: bool = False): 191 | deadline = time.time() + 10 192 | while time.time() < deadline: 193 | try: 194 | httpx.get(f"{local_server_url}/api/info", timeout=1) 195 | break 196 | except Exception as e: 197 | time.sleep(0.5) 198 | import webbrowser 199 | web_url = get_webpage_url(local_server_url if offline else None) 200 | logger.info("open browser: %s", web_url) 201 | webbrowser.open(web_url) 202 | 203 | 204 | def main(): 205 | has_command = False 206 | for name in sys.argv[1:]: 207 | if not name.startswith("-"): 208 | has_command = True 209 | 210 | if not has_command: 211 | cli.main(args=sys.argv[1:] + ["server"], prog_name="uiauto.dev") 212 | else: 213 | cli() 214 | 215 | 216 | if __name__ == "__main__": 217 | main() 218 | -------------------------------------------------------------------------------- /uiautodev/remote/scrcpy.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | import os 5 | import socket 6 | import struct 7 | from pathlib import Path 8 | from typing import Optional 9 | 10 | import retry 11 | from adbutils import AdbError, Network, adb 12 | from adbutils._adb import AdbConnection 13 | from adbutils._device import AdbDevice 14 | from starlette.websockets import WebSocket, WebSocketDisconnect 15 | 16 | from uiautodev.remote.touch_controller import ScrcpyTouchController 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | class ScrcpyServer: 22 | """ 23 | ScrcpyServer class is responsible for managing the scrcpy server on Android devices. 24 | It handles the initialization, communication, and control of the scrcpy server, 25 | including video streaming and touch control. 26 | """ 27 | 28 | def __init__(self, device: AdbDevice, version: Optional[str] = "2.7"): 29 | """ 30 | Initializes the ScrcpyServer instance. 31 | 32 | Args: 33 | device (AdbDevice): The ADB device instance to use. 34 | version (str, optional): Scrcpy server version to use. Defaults to "2.7". 35 | """ 36 | self.scrcpy_jar_path = Path(__file__).parent.joinpath(f'../binaries/scrcpy-server-v{version}.jar') 37 | if self.scrcpy_jar_path.exists() is False: 38 | raise FileNotFoundError(f"Scrcpy server JAR not found: {self.scrcpy_jar_path}") 39 | self.device = device 40 | self.version = version 41 | self.resolution_width = 0 # scrcpy 投屏转换宽度 42 | self.resolution_height = 0 # scrcpy 投屏转换高度 43 | 44 | self._shell_conn: AdbConnection 45 | self._video_conn: socket.socket 46 | self._control_conn: socket.socket 47 | 48 | self._setup_connection() 49 | 50 | def _setup_connection(self): 51 | self._shell_conn = self._start_scrcpy_server(control=True) 52 | self._video_conn = self._connect_scrcpy(self.device) 53 | self._control_conn = self._connect_scrcpy(self.device) 54 | self._parse_scrcpy_info(self._video_conn) 55 | 56 | self.controller = ScrcpyTouchController(self._control_conn) 57 | 58 | @retry.retry(exceptions=AdbError, tries=20, delay=0.1) 59 | def _connect_scrcpy(self, device: AdbDevice) -> socket.socket: 60 | return device.create_connection(Network.LOCAL_ABSTRACT, 'scrcpy') 61 | 62 | def _parse_scrcpy_info(self, conn: socket.socket): 63 | dummy_byte = conn.recv(1) 64 | if not dummy_byte or dummy_byte != b"\x00": 65 | raise ConnectionError("Did not receive Dummy Byte!") 66 | logger.debug('Received Dummy Byte!') 67 | # print('Received Dummy Byte!') 68 | if self.version == '3.3.3': # 临时处理一下, 3.3.3使用WebCodec来接码,前端解析分辨率 69 | return 70 | device_name = conn.recv(64).decode('utf-8').rstrip('\x00') 71 | logger.debug(f'Device name: {device_name}') 72 | codec = conn.recv(4) 73 | logger.debug(f'resolution_data: {codec}') 74 | resolution_data = conn.recv(8) 75 | logger.debug(f'resolution_data: {resolution_data}') 76 | self.resolution_width, self.resolution_height = struct.unpack(">II", resolution_data) 77 | logger.debug(f'Resolution: {self.resolution_width}x{self.resolution_height}') 78 | 79 | def close(self): 80 | try: 81 | self._control_conn.close() 82 | self._video_conn.close() 83 | self._shell_conn.close() 84 | except: 85 | pass 86 | 87 | def __del__(self): 88 | self.close() 89 | 90 | def _start_scrcpy_server(self, control: bool = True) -> AdbConnection: 91 | """ 92 | Pushes the scrcpy server JAR file to the Android device and starts the scrcpy server. 93 | 94 | Args: 95 | control (bool, optional): Whether to enable touch control. Defaults to True. 96 | 97 | Returns: 98 | AdbConnection 99 | """ 100 | # 获取设备对象 101 | device = self.device 102 | 103 | # 推送 scrcpy 服务器到设备 104 | device.sync.push(self.scrcpy_jar_path, '/data/local/tmp/scrcpy_server.jar', check=True) 105 | logger.info('scrcpy server JAR pushed to device') 106 | 107 | # 构建启动 scrcpy 服务器的命令 108 | cmds = [ 109 | 'CLASSPATH=/data/local/tmp/scrcpy_server.jar', 110 | 'app_process', '/', 111 | f'com.genymobile.scrcpy.Server', self.version, 112 | 'log_level=info', 'max_size=1024', 'max_fps=30', 113 | 'video_bit_rate=8000000', 'tunnel_forward=true', 114 | 'send_frame_meta='+('true' if self.version == '3.3.3' else 'false'), 115 | f'control={"true" if control else "false"}', 116 | 'audio=false', 'show_touches=false', 'stay_awake=false', 117 | 'power_off_on_close=false', 'clipboard_autosync=false' 118 | ] 119 | conn = device.shell(' '.join(cmds), stream=True) 120 | logger.debug("scrcpy output: %s", conn.conn.recv(100)) 121 | return conn # type: ignore 122 | 123 | async def handle_unified_websocket(self, websocket: WebSocket, serial=''): 124 | logger.info(f"[Unified] WebSocket connection from {websocket} for serial: {serial}") 125 | 126 | video_task = asyncio.create_task(self._stream_video_to_websocket(self._video_conn, websocket)) 127 | control_task = asyncio.create_task(self._handle_control_websocket(websocket)) 128 | 129 | try: 130 | # 不使用 return_exceptions=True,让异常能够正确传播 131 | await asyncio.gather(video_task, control_task) 132 | finally: 133 | # 取消任务 134 | for task in (video_task, control_task): 135 | if not task.done(): 136 | task.cancel() 137 | logger.info(f"[Unified] WebSocket closed for serial={serial}") 138 | 139 | async def _stream_video_to_websocket(self, conn: socket.socket, ws: WebSocket): 140 | # Set socket to non-blocking mode 141 | conn.setblocking(False) 142 | 143 | while True: 144 | # check if ws closed 145 | if ws.client_state.name != "CONNECTED": 146 | logger.info('WebSocket no longer connected. Exiting video stream.') 147 | break 148 | # Use asyncio to read data asynchronously 149 | data = await asyncio.get_event_loop().sock_recv(conn, 1024 * 1024) 150 | if not data: 151 | logger.warning('No data received, connection may be closed.') 152 | raise ConnectionError("Video stream ended unexpectedly") 153 | # send data to ws 154 | await ws.send_bytes(data) 155 | 156 | async def _handle_control_websocket(self, ws: WebSocket): 157 | while True: 158 | try: 159 | message = await ws.receive_text() 160 | logger.debug(f"[Unified] Received message: {message}") 161 | message = json.loads(message) 162 | 163 | width, height = self.resolution_width, self.resolution_height 164 | message_type = message.get('type') 165 | if message_type == 'touchMove': 166 | xP = message['xP'] 167 | yP = message['yP'] 168 | self.controller.move(int(xP * width), int(yP * height), width, height) 169 | elif message_type == 'touchDown': 170 | xP = message['xP'] 171 | yP = message['yP'] 172 | self.controller.down(int(xP * width), int(yP * height), width, height) 173 | elif message_type == 'touchUp': 174 | xP = message['xP'] 175 | yP = message['yP'] 176 | self.controller.up(int(xP * width), int(yP * height), width, height) 177 | elif message_type == 'keyEvent': 178 | event_number = message['data']['eventNumber'] 179 | self.device.shell(f'input keyevent {event_number}') 180 | elif message_type == 'text': 181 | text = message['detail'] 182 | self.device.shell(f'am broadcast -a SONIC_KEYBOARD --es msg \'{text}\'') 183 | elif message_type == 'ping': 184 | await ws.send_text(json.dumps({"type": "pong"})) 185 | except json.JSONDecodeError as e: 186 | logger.error(f"Invalid JSON message: {e}") 187 | continue 188 | -------------------------------------------------------------------------------- /uiautodev/app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Sun Feb 18 2024 13:48:55 by codeskyblue""" 5 | 6 | import logging 7 | import os 8 | import platform 9 | import signal 10 | from pathlib import Path 11 | from typing import Dict, List 12 | 13 | import adbutils 14 | import httpx 15 | import uvicorn 16 | from fastapi import FastAPI, File, Request, Response, UploadFile, WebSocket 17 | from fastapi.middleware.cors import CORSMiddleware 18 | from fastapi.responses import FileResponse, JSONResponse, RedirectResponse 19 | from pydantic import BaseModel 20 | from starlette.websockets import WebSocketDisconnect 21 | 22 | from uiautodev import __version__ 23 | from uiautodev.common import convert_bytes_to_image, get_webpage_url, ocr_image 24 | from uiautodev.driver.android import ADBAndroidDriver, U2AndroidDriver 25 | from uiautodev.model import Node 26 | from uiautodev.provider import AndroidProvider, HarmonyProvider, IOSProvider, MockProvider 27 | from uiautodev.remote.scrcpy import ScrcpyServer 28 | from uiautodev.router.android import router as android_device_router 29 | from uiautodev.router.device import make_router 30 | from uiautodev.router.proxy import make_reverse_proxy 31 | from uiautodev.router.proxy import router as proxy_router 32 | from uiautodev.router.xml import router as xml_router 33 | from uiautodev.utils.envutils import Environment 34 | 35 | logger = logging.getLogger(__name__) 36 | 37 | app = FastAPI() 38 | 39 | app.add_middleware( 40 | CORSMiddleware, 41 | allow_origins=["*"], 42 | allow_credentials=True, 43 | allow_methods=["GET", "POST"], 44 | allow_headers=["*"], 45 | ) 46 | 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)) 53 | ios_router = make_router(IOSProvider()) 54 | harmony_router = make_router(HarmonyProvider()) 55 | mock_router = make_router(MockProvider()) 56 | 57 | app.include_router(mock_router, prefix="/api/mock", tags=["mock"]) 58 | 59 | if Environment.UIAUTODEV_MOCK: 60 | app.include_router(mock_router, prefix="/api/android", tags=["mock"]) 61 | app.include_router(mock_router, prefix="/api/ios", tags=["mock"]) 62 | app.include_router(mock_router, prefix="/api/harmony", tags=["mock"]) 63 | else: 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"]) 66 | app.include_router(ios_router, prefix="/api/ios", tags=["ios"]) 67 | app.include_router(harmony_router, prefix="/api/harmony", tags=["harmony"]) 68 | 69 | app.include_router(xml_router, prefix="/api/xml", tags=["xml"]) 70 | app.include_router(android_device_router, prefix="/api/android", tags=["android"]) 71 | app.include_router(proxy_router, tags=["proxy"]) 72 | 73 | 74 | @app.get("/api/{platform}/features") 75 | def get_features(platform: str) -> Dict[str, bool]: 76 | """Get features supported by the specified platform""" 77 | features = {} 78 | # 获取所有带有指定平台tag的路由 79 | from starlette.routing import Route 80 | 81 | for route in app.routes: 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}}/"): 85 | # 提取特性名称 86 | parts = _route.path.split("/") 87 | feature_name = parts[-1] 88 | if not feature_name.startswith("{"): 89 | features[feature_name] = True 90 | return features 91 | 92 | 93 | class InfoResponse(BaseModel): 94 | version: str 95 | description: str 96 | platform: str 97 | code_language: str 98 | cwd: str 99 | drivers: List[str] 100 | 101 | 102 | @app.get("/api/info") 103 | def info() -> InfoResponse: 104 | """Information about the application""" 105 | return InfoResponse( 106 | version=__version__, 107 | description="client for https://uiauto.dev", 108 | platform=platform.system(), # Linux | Darwin | Windows 109 | code_language="Python", 110 | cwd=os.getcwd(), 111 | drivers=["android", "ios", "harmony"], 112 | ) 113 | 114 | 115 | @app.post("/api/ocr_image") 116 | async def _ocr_image(file: UploadFile = File(...)) -> List[Node]: 117 | """OCR an image""" 118 | image_data = await file.read() 119 | image = convert_bytes_to_image(image_data) 120 | return ocr_image(image) 121 | 122 | 123 | @app.get("/shutdown") 124 | def shutdown() -> str: 125 | """Shutdown the server""" 126 | os.kill(os.getpid(), signal.SIGINT) 127 | return "Server shutting down..." 128 | 129 | 130 | @app.get("/demo") 131 | def demo(): 132 | """Demo endpoint""" 133 | static_dir = Path(__file__).parent / "static" 134 | print(static_dir / "demo.html") 135 | return FileResponse(static_dir / "demo.html") 136 | 137 | 138 | @app.get("/redirect") 139 | def index_redirect(): 140 | """redirect to official homepage""" 141 | url = get_webpage_url() 142 | logger.debug("redirect to %s", url) 143 | return RedirectResponse(url) 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 | 151 | @app.websocket('/ws/android/scrcpy3/{serial}') 152 | async def handle_android_scrcpy3_ws(websocket: WebSocket, serial: str): 153 | await websocket.accept() 154 | try: 155 | logger.info(f"WebSocket serial: {serial}") 156 | device = adbutils.device(serial) 157 | from uiautodev.remote.scrcpy3 import ScrcpyServer3 158 | scrcpy = ScrcpyServer3(device) 159 | try: 160 | await scrcpy.stream_to_websocket(websocket) 161 | finally: 162 | scrcpy.close() 163 | except WebSocketDisconnect: 164 | logger.info(f"WebSocket disconnected by client.") 165 | except Exception as e: 166 | logger.exception(f"WebSocket error for serial={serial}: {e}") 167 | reason = str(e).replace("\n", " ") 168 | await websocket.close(code=1000, reason=reason) 169 | finally: 170 | logger.info(f"WebSocket closed for serial={serial}") 171 | 172 | @app.websocket("/ws/android/scrcpy/{serial}") 173 | async def handle_android_ws(websocket: WebSocket, serial: str): 174 | """ 175 | Args: 176 | serial: device serial 177 | websocket: WebSocket 178 | """ 179 | scrcpy_version = websocket.query_params.get("version", "2.7") 180 | await websocket.accept() 181 | 182 | try: 183 | logger.info(f"WebSocket serial: {serial}") 184 | device = adbutils.device(serial) 185 | server = ScrcpyServer(device, version=scrcpy_version) 186 | await server.handle_unified_websocket(websocket, serial) 187 | except WebSocketDisconnect: 188 | logger.info(f"WebSocket disconnected by client.") 189 | except Exception as e: 190 | logger.exception(f"WebSocket error for serial={serial}: {e}") 191 | await websocket.close(code=1000, reason=str(e)) 192 | finally: 193 | logger.info(f"WebSocket closed for serial={serial}") 194 | 195 | 196 | def get_harmony_mjpeg_server(serial: str): 197 | from hypium import UiDriver 198 | 199 | from uiautodev.remote.harmony_mjpeg import HarmonyMjpegServer 200 | 201 | driver = UiDriver.connect(device_sn=serial) 202 | logger.info("create harmony mjpeg server for %s", serial) 203 | logger.info(f"device wake_up_display: {driver.wake_up_display()}") 204 | return HarmonyMjpegServer(driver) 205 | 206 | 207 | @app.websocket("/ws/harmony/mjpeg/{serial}") 208 | async def unified_harmony_ws(websocket: WebSocket, serial: str): 209 | """ 210 | Args: 211 | serial: device serial 212 | websocket: WebSocket 213 | """ 214 | await websocket.accept() 215 | 216 | try: 217 | logger.info(f"WebSocket serial: {serial}") 218 | 219 | # 获取 HarmonyScrcpyServer 实例 220 | server = get_harmony_mjpeg_server(serial) 221 | server.start() 222 | await server.handle_ws(websocket) 223 | except ImportError as e: 224 | logger.error(f"missing library for harmony: {e}") 225 | await websocket.close( 226 | code=1000, reason='missing library, fix by "pip install uiautodev[harmony]"' 227 | ) 228 | except WebSocketDisconnect: 229 | logger.info(f"WebSocket disconnected by client.") 230 | except Exception as e: 231 | logger.exception(f"WebSocket error for serial={serial}: {e}") 232 | await websocket.close(code=1000, reason=str(e)) 233 | finally: 234 | logger.info(f"WebSocket closed for serial={serial}") 235 | 236 | 237 | if __name__ == "__main__": 238 | uvicorn.run("uiautodev.app:app", port=4000, reload=True, use_colors=True) 239 | -------------------------------------------------------------------------------- /uiautodev/remote/harmony_mjpeg.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | import socket 5 | from datetime import datetime 6 | from threading import Thread 7 | 8 | from fastapi import WebSocket 9 | from hypium import KeyCode 10 | 11 | from uiautodev.exceptions import HarmonyDriverException 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class HarmonyMjpegServer: 17 | """ 18 | HarmonyMjpegServer is responsible for handling screen streaming functionality 19 | for HarmonyOS devices that support ABC proxy (a communication interface). 20 | 21 | It manages WebSocket clients, communicates with the ABC server over gRPC, and streams 22 | the device's screen data in real-time to connected clients. 23 | 24 | This server is specifically designed for devices running in 'abc mode' and requires that 25 | the target device expose an `abc_proxy` attribute for communication. 26 | 27 | Attributes: 28 | device: The HarmonyOS device object. 29 | driver: The controlling driver which may wrap the device. 30 | abc_rpc_addr: Tuple containing the IP and port used to communicate with abc_proxy. 31 | channel: The gRPC communication channel (initialized later). 32 | clients: A set of connected WebSocket clients. 33 | loop: Asyncio event loop used to run asynchronous tasks. 34 | is_running: Boolean flag indicating if the streaming service is active. 35 | 36 | Raises: 37 | RuntimeError: If the connected device does not support abc_proxy. 38 | 39 | References: 40 | - Huawei HarmonyOS Python Guidelines: 41 | https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/hypium-python-guidelines 42 | """ 43 | 44 | def __init__(self, driver): 45 | if hasattr(driver, "_device"): 46 | device = driver._device 47 | else: 48 | device = driver 49 | logger.info(f'device: {device}') 50 | if not hasattr(device, "abc_proxy") or device.abc_proxy is None: 51 | raise HarmonyDriverException("Only abc mode can support screen recorder") 52 | self.device = device 53 | self.driver = driver 54 | self.abc_rpc_addr = ("127.0.0.1", device.abc_proxy.port) 55 | self.channel = None 56 | self.clients = set() 57 | self.loop = asyncio.get_event_loop() 58 | self.is_running = False 59 | 60 | def connect(self): 61 | self.channel = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 62 | self.channel.connect(self.abc_rpc_addr) 63 | 64 | def start(self, timeout=3600): 65 | if self.channel is None: 66 | self.connect() 67 | self.is_running = True 68 | self.timeout = timeout 69 | self.stop_capture_if_running() 70 | msg_json = {'api': "startCaptureScreen", 'args': []} 71 | full_msg = { 72 | "module": "com.ohos.devicetest.hypiumApiHelper", 73 | "method": "Captures", 74 | "params": msg_json, 75 | "request_id": datetime.now().strftime("%Y%m%d%H%M%S%f") 76 | } 77 | full_msg_str = json.dumps(full_msg, ensure_ascii=False, separators=(',', ':')) 78 | self.channel.sendall(full_msg_str.encode("utf-8") + b'\n') 79 | reply = self.channel.recv(1024) 80 | logger.info(f'reply: {reply}') 81 | if b"true" in reply: 82 | thread_record = Thread(target=self._record_worker) 83 | thread_record.start() 84 | else: 85 | raise RuntimeError("Fail to start screen capture") 86 | 87 | def stop_capture_if_running(self): 88 | msg_json = {'api': "stopCaptureScreen", 'args': []} 89 | full_msg = { 90 | "module": "com.ohos.devicetest.hypiumApiHelper", 91 | "method": "Captures", 92 | "params": msg_json, 93 | "request_id": datetime.now().strftime("%Y%m%d%H%M%S%f") 94 | } 95 | full_msg_str = json.dumps(full_msg, ensure_ascii=False, separators=(',', ':')) 96 | self.channel.sendall(full_msg_str.encode("utf-8") + b'\n') 97 | reply = self.channel.recv(1024) 98 | logger.info(f'stop reply: {reply}') 99 | 100 | async def handle_ws(self, websocket: WebSocket): 101 | self.clients.add(websocket) 102 | serial = getattr(self.device, "device_sn", "unknown") 103 | logger.info(f"[{serial}] WebSocket connected") 104 | 105 | try: 106 | while True: 107 | message = await websocket.receive_text() 108 | logger.info(f"Received message: {message}") 109 | try: 110 | data = json.loads(message) 111 | if data.get('type') == 'touch': 112 | action = data.get('action') 113 | x, y = data.get('x'), data.get('y') 114 | if action == 'normal': 115 | self.driver.touch((x, y)) 116 | elif action == 'long': 117 | self.driver.touch(target=(x, y), mode='long') 118 | elif action == 'double': 119 | self.driver.touch(target=(x, y), mode='double') 120 | elif action == 'move': 121 | self.driver.slide( 122 | start=(data.get('x1'), data.get('y1')), 123 | end=(data.get('x2'), data.get('y2')), 124 | slide_time=0.1 125 | ) 126 | elif data.get('type') == 'keyEvent': 127 | event_number = data['eventNumber'] 128 | if event_number == 187: 129 | self.driver.swipe_to_recent_task() 130 | elif event_number == 3: 131 | self.driver.go_home() 132 | elif event_number == 4: 133 | self.driver.go_back() 134 | elif event_number == 224: 135 | self.driver.wake_up_display() 136 | elif data.get('type') == 'text': 137 | detail = data.get('detail') 138 | if detail == 'CODE_AC_BACK': 139 | self.driver.press_key(KeyCode.DEL) 140 | elif detail == 'CODE_AC_ENTER': 141 | self.driver.press_key(KeyCode.ENTER) 142 | else: 143 | self.driver.shell( 144 | f"uitest uiInput inputText {data.get('x')} {data.get('y')} {detail}") 145 | except Exception as e: 146 | logger.warning(f"Failed to handle message: {e}") 147 | except Exception as e: 148 | logger.info(f"WebSocket closed: {e}") 149 | finally: 150 | self.clients.discard(websocket) 151 | 152 | def _record_worker(self): 153 | tmp_data = b'' 154 | start_flag = b'\xff\xd8' 155 | end_flag = b'\xff\xd9' 156 | while self.is_running: 157 | try: 158 | result = self.channel.recv(4096 * 1024) 159 | tmp_data += result 160 | while start_flag in tmp_data and end_flag in tmp_data: 161 | start_index = tmp_data.index(start_flag) 162 | end_index = tmp_data.index(end_flag) + 2 163 | frame = tmp_data[start_index:end_index] 164 | tmp_data = tmp_data[end_index:] 165 | asyncio.run_coroutine_threadsafe(self._broadcast(frame), self.loop) 166 | except Exception as e: 167 | logger.warning(f"Record worker error: {e}") 168 | self.is_running = False 169 | self.channel = None 170 | break 171 | 172 | async def _broadcast(self, data): 173 | for client in self.clients.copy(): 174 | try: 175 | await client.send_bytes(data) 176 | except Exception as e: 177 | logger.info(f"Send error, removing client: {e}") 178 | self.clients.discard(client) 179 | 180 | def stop(self): 181 | self.is_running = False 182 | if self.channel is None: 183 | return 184 | msg_json = {'api': "stopCaptureScreen", 'args': []} 185 | full_msg = { 186 | "module": "com.ohos.devicetest.hypiumApiHelper", 187 | "method": "Captures", 188 | "params": msg_json, 189 | "request_id": datetime.now().strftime("%Y%m%d%H%M%S%f") 190 | } 191 | full_msg_str = json.dumps(full_msg, ensure_ascii=False, separators=(',', ':')) 192 | self.channel.sendall(full_msg_str.encode("utf-8") + b'\n') 193 | reply = self.channel.recv(1024) 194 | if b"true" not in reply: 195 | logger.info("Fail to stop capture") 196 | self.channel.close() 197 | self.channel = None 198 | for client in self.clients: 199 | asyncio.run_coroutine_threadsafe(client.close(), self.loop) 200 | -------------------------------------------------------------------------------- /uiautodev/driver/udt/udt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Sun Apr 21 2024 21:15:15 by codeskyblue 5 | """ 6 | 7 | 8 | import atexit 9 | import enum 10 | import io 11 | import json 12 | import logging 13 | import threading 14 | import time 15 | from base64 import b64decode 16 | from pathlib import Path 17 | from pprint import pprint 18 | from typing import Any, Optional 19 | 20 | import adbutils 21 | import requests 22 | from PIL import Image 23 | from pydantic import BaseModel 24 | 25 | """ 26 | shell steps: 27 | adb push appium-uiautomator2-v5.12.4.apk /data/local/tmp/udt.jar 28 | adb shell CLASSPATH=/data/local/tmp/udt.jar app_process / "com.wetest.uia2.Main" 29 | adb forward tcp:6790 tcp:6790 30 | # 创建session 31 | echo '{"capabilities": {}}' | http POST :6790/session 32 | # 获取当前所有session 33 | http GET :6790/sessions 34 | # 获取pageSource 35 | http GET :6790/session/{session_id}/source 36 | 37 | # TODO 38 | # /appium/settins 中waitForIdleTimeout需要调整,其他的再看看 39 | """ 40 | 41 | logger = logging.getLogger(__name__) 42 | 43 | class UDTError(Exception): 44 | pass 45 | 46 | 47 | class HTTPError(UDTError): 48 | pass 49 | 50 | 51 | class AppiumErrorEnum(str, enum.Enum): 52 | InvalidSessionID = 'invalid session id' 53 | 54 | 55 | class AppiumError(UDTError): 56 | def __init__(self, error: str, message): 57 | self.error = error 58 | self.message = message 59 | 60 | 61 | class AppiumResponseValue(BaseModel): 62 | error: Optional[str] = None 63 | message: Optional[str] = None 64 | stacktrace: Optional[str] = None 65 | 66 | 67 | class AppiumResponse(BaseModel): 68 | sessionId: Optional[str] = None 69 | value: Any = None 70 | 71 | 72 | class MockAdbProcess: 73 | def __init__(self, conn: adbutils.AdbConnection) -> None: 74 | self._conn = conn 75 | self._event = threading.Event() 76 | 77 | def wait_finished(): 78 | try: 79 | self._conn.read_until_close() 80 | except: 81 | pass 82 | self._event.set() 83 | 84 | t = threading.Thread(target=wait_finished) 85 | t.daemon = True 86 | t.name = "wait_adb_conn" 87 | t.start() 88 | 89 | def wait(self) -> int: 90 | self._event.wait() 91 | return 0 92 | 93 | def pool(self) -> Optional[int]: 94 | if self._event.is_set(): 95 | return 0 96 | return None 97 | 98 | def kill(self): 99 | self._conn.close() 100 | 101 | 102 | class UDT: 103 | def __init__(self, device: adbutils.AdbDevice): 104 | self._device = device 105 | self._lport = None 106 | self._process = None 107 | self._lock = threading.Lock() 108 | self._session_id = None 109 | atexit.register(self.release) 110 | 111 | def get_session_id(self) -> str: 112 | if self._session_id: 113 | return self._session_id 114 | self._session_id = self._new_session() 115 | logger.debug("update waitForIdleTimeout to 0ms") 116 | self._dev_request("POST", f"/session/{self._session_id}/appium/settings", payload={ 117 | "settings": { 118 | "waitForIdleTimeout": 10, 119 | "waitForSelectorTimeout": 10, 120 | "actionAcknowledgmentTimeout": 10, 121 | "scrollAcknowledgmentTimeout": 10, 122 | "trackScrollEvents": False, 123 | } 124 | }) 125 | result = self._dev_request("GET", f"/session/{self._session_id}/appium/settings") 126 | return self._session_id 127 | 128 | def dev_request(self, method: str, path: str, **kwargs) -> AppiumResponse: 129 | """send http request to device 130 | :param method: GET, POST, DELETE, PUT 131 | :param path: url path, path start with @ means with_session=True 132 | 133 | :return: response json 134 | """ 135 | try: 136 | if path.startswith("@"): 137 | path = path[1:] 138 | kwargs['with_session'] = True 139 | return self._dev_request(method, path, **kwargs) 140 | except HTTPError: 141 | self.launch_server() 142 | return self._dev_request(method, path, **kwargs) 143 | except AppiumError as e: 144 | if e.error == AppiumErrorEnum.InvalidSessionID: 145 | self._session_id = self._new_session() 146 | return self._dev_request(method, path, **kwargs) 147 | raise 148 | 149 | def _dev_request(self, method: str, path: str, payload=None, timeout: float = 10.0, with_session: bool = False) -> AppiumResponse: 150 | try: 151 | if with_session: 152 | sid = self.get_session_id() 153 | path = f"/session/{sid}{path}" 154 | url = f"http://localhost:{self._lport}{path}" 155 | logger.debug("request %s %s", method, url) 156 | r = requests.request(method, url, json=payload, timeout=timeout) 157 | response_json = r.json() 158 | resp = AppiumResponse.model_validate(response_json) 159 | if isinstance(resp.value, dict): 160 | value = AppiumResponseValue.model_validate(resp.value) 161 | if value.error: 162 | raise AppiumError(value.error, value.message) 163 | return resp 164 | except requests.RequestException as e: 165 | raise HTTPError(f"{method} to {path!r} error", payload) 166 | except json.JSONDecodeError as e: 167 | raise HTTPError("JSON decode error", e.msg) 168 | 169 | def _new_session(self) -> str: 170 | resp = self._dev_request("POST", "/session", payload={"capabilities": {}}) 171 | value = resp.value 172 | if not isinstance(value, dict) and 'sessionId' not in value: 173 | raise UDTError("session create failed", resp) 174 | sid = value['sessionId'] 175 | if not sid: 176 | raise UDTError("session create failed", resp) 177 | return sid 178 | 179 | def post(self, path: str, payload=None) -> AppiumResponse: 180 | return self.dev_request("POST", path, payload=payload) 181 | 182 | def get(self, path: str, ) -> AppiumResponse: 183 | return self.dev_request("GET", path) 184 | 185 | def _update_process_status(self): 186 | if self._process: 187 | if self._process.pool() is not None: 188 | self._process = None 189 | 190 | def release(self): 191 | logger.debug("Releasing") 192 | with self._lock: 193 | if self._process is not None: 194 | logger.debug("Killing process") 195 | self._process.kill() 196 | self._process.wait() 197 | self._process = None 198 | 199 | def launch_server(self): 200 | try: 201 | self._launch_server() 202 | self._device.keyevent("WAKEUP") 203 | except adbutils.AdbError as e: 204 | raise UDTError("fail to start udt", str(e)) 205 | self._wait_ready() 206 | 207 | def _launch_server(self): 208 | with self._lock: 209 | self._update_process_status() 210 | if self._process: 211 | logger.debug("Process already running") 212 | return 213 | logger.debug("Launching process") 214 | dex_local_path = Path(__file__).parent.joinpath("appium-uiautomator2-v5.12.4-light.apk") 215 | logger.debug("dex_local_path: %s", dex_local_path) 216 | dex_remote_path = "/data/local/tmp/udt/udt-5.12.4-light.dex" 217 | info = self._device.sync.stat(dex_remote_path) 218 | if info.size == dex_local_path.stat().st_size: 219 | logger.debug("%s already exists", dex_remote_path) 220 | else: 221 | logger.debug("push dex(%d) to %s", dex_local_path.stat().st_size, dex_remote_path) 222 | self._device.shell("mkdir -p /data/local/tmp/udt") 223 | self._device.sync.push(dex_local_path, dex_remote_path, 0o644) 224 | logger.debug("CLASSPATH=%s app_process / com.wetest.uia2.Main", dex_remote_path) 225 | conn = self._device.shell(f"CLASSPATH={dex_remote_path} app_process / com.wetest.uia2.Main", stream=True) 226 | self._process = MockAdbProcess(conn) 227 | 228 | self._lport = self._device.forward_port(6790) 229 | logger.debug("forward tcp:6790 -> tcp:%d", self._lport) 230 | 231 | def _wait_ready(self): 232 | deadline = time.time() + 10 233 | while time.time() < deadline: 234 | try: 235 | self._dev_request("GET", "/status", timeout=1) 236 | return 237 | except HTTPError: 238 | time.sleep(0.5) 239 | raise UDTError("Service not ready") 240 | 241 | def dump_hierarchy(self) -> str: 242 | resp = self.get(f"@/source") 243 | return resp.value 244 | 245 | def status(self): 246 | return self.get("/status") 247 | 248 | def screenshot(self) -> Image.Image: 249 | resp = self.get(f"@/screenshot") 250 | raw = b64decode(resp.value) 251 | return Image.open(io.BytesIO(raw)) 252 | 253 | 254 | 255 | if __name__ == '__main__': 256 | logging.basicConfig(level=logging.DEBUG) 257 | r = UDT(adbutils.device()) 258 | print(r.status()) 259 | r.dump_hierarchy() 260 | -------------------------------------------------------------------------------- /uiautodev/remote/keycode.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum 2 | 3 | 4 | class KeyCode(IntEnum): 5 | """Android key codes ported from Android's KeyEvent class 6 | 7 | This enum contains all the key codes defined in Android's KeyEvent class, 8 | which are used for sending key events to Android devices through scrcpy. 9 | 10 | The comments for each key code are taken directly from the Android source code 11 | to maintain compatibility and provide accurate descriptions. 12 | """ 13 | # Unknown key code 14 | UNKNOWN = 0 15 | # Soft Left key - Usually situated below the display on phones 16 | SOFT_LEFT = 1 17 | # Soft Right key - Usually situated below the display on phones 18 | SOFT_RIGHT = 2 19 | # Home key - This key is handled by the framework and is never delivered to applications 20 | HOME = 3 21 | # Back key 22 | BACK = 4 23 | # Call key 24 | CALL = 5 25 | # End Call key 26 | ENDCALL = 6 27 | # '0' key 28 | KEY_0 = 7 29 | # '1' key 30 | KEY_1 = 8 31 | # '2' key 32 | KEY_2 = 9 33 | # '3' key 34 | KEY_3 = 10 35 | # '4' key 36 | KEY_4 = 11 37 | # '5' key 38 | KEY_5 = 12 39 | # '6' key 40 | KEY_6 = 13 41 | # '7' key 42 | KEY_7 = 14 43 | # '8' key 44 | KEY_8 = 15 45 | # '9' key 46 | KEY_9 = 16 47 | # '*' key 48 | STAR = 17 49 | # '#' key 50 | POUND = 18 51 | # Directional Pad Up key - May also be synthesized from trackball motions 52 | DPAD_UP = 19 53 | # Directional Pad Down key - May also be synthesized from trackball motions 54 | DPAD_DOWN = 20 55 | # Directional Pad Left key - May also be synthesized from trackball motions 56 | DPAD_LEFT = 21 57 | # Directional Pad Right key - May also be synthesized from trackball motions 58 | DPAD_RIGHT = 22 59 | # Directional Pad Center key - May also be synthesized from trackball motions 60 | DPAD_CENTER = 23 61 | # Volume Up key - Adjusts the speaker volume up 62 | VOLUME_UP = 24 63 | # Volume Down key - Adjusts the speaker volume down 64 | VOLUME_DOWN = 25 65 | # Power key 66 | POWER = 26 67 | # Camera key - Used to launch a camera application or take pictures 68 | CAMERA = 27 69 | # Clear key 70 | CLEAR = 28 71 | A = 29 72 | B = 30 73 | C = 31 74 | D = 32 75 | E = 33 76 | F = 34 77 | G = 35 78 | H = 36 79 | I = 37 80 | J = 38 81 | K = 39 82 | L = 40 83 | M = 41 84 | N = 42 85 | O = 43 86 | P = 44 87 | Q = 45 88 | R = 46 89 | S = 47 90 | T = 48 91 | U = 49 92 | V = 50 93 | W = 51 94 | X = 52 95 | Y = 53 96 | Z = 54 97 | COMMA = 55 98 | PERIOD = 56 99 | ALT_LEFT = 57 100 | ALT_RIGHT = 58 101 | SHIFT_LEFT = 59 102 | SHIFT_RIGHT = 60 103 | TAB = 61 104 | SPACE = 62 105 | SYM = 63 106 | EXPLORER = 64 107 | ENVELOPE = 65 108 | # Enter key 109 | ENTER = 66 110 | # Backspace key - Deletes characters before the insertion point 111 | DEL = 67 112 | GRAVE = 68 113 | MINUS = 69 114 | EQUALS = 70 115 | LEFT_BRACKET = 71 116 | RIGHT_BRACKET = 72 117 | BACKSLASH = 73 118 | SEMICOLON = 74 119 | APOSTROPHE = 75 120 | SLASH = 76 121 | AT = 77 122 | NUM = 78 123 | HEADSETHOOK = 79 124 | FOCUS = 80 125 | PLUS = 81 126 | # Menu key 127 | MENU = 82 128 | NOTIFICATION = 83 129 | SEARCH = 84 130 | MEDIA_PLAY_PAUSE = 85 131 | MEDIA_STOP = 86 132 | MEDIA_NEXT = 87 133 | MEDIA_PREVIOUS = 88 134 | MEDIA_REWIND = 89 135 | MEDIA_FAST_FORWARD = 90 136 | MUTE = 91 137 | PAGE_UP = 92 138 | PAGE_DOWN = 93 139 | PICTSYMBOLS = 94 140 | SWITCH_CHARSET = 95 141 | BUTTON_A = 96 142 | BUTTON_B = 97 143 | BUTTON_C = 98 144 | BUTTON_X = 99 145 | BUTTON_Y = 100 146 | BUTTON_Z = 101 147 | BUTTON_L1 = 102 148 | BUTTON_R1 = 103 149 | BUTTON_L2 = 104 150 | BUTTON_R2 = 105 151 | BUTTON_THUMBL = 106 152 | BUTTON_THUMBR = 107 153 | BUTTON_START = 108 154 | BUTTON_SELECT = 109 155 | BUTTON_MODE = 110 156 | ESCAPE = 111 157 | FORWARD_DEL = 112 158 | CTRL_LEFT = 113 159 | CTRL_RIGHT = 114 160 | CAPS_LOCK = 115 161 | SCROLL_LOCK = 116 162 | META_LEFT = 117 163 | META_RIGHT = 118 164 | FUNCTION = 119 165 | SYSRQ = 120 166 | BREAK = 121 167 | MOVE_HOME = 122 168 | MOVE_END = 123 169 | INSERT = 124 170 | FORWARD = 125 171 | MEDIA_PLAY = 126 172 | MEDIA_PAUSE = 127 173 | MEDIA_CLOSE = 128 174 | MEDIA_EJECT = 129 175 | MEDIA_RECORD = 130 176 | F1 = 131 177 | F2 = 132 178 | F3 = 133 179 | F4 = 134 180 | F5 = 135 181 | F6 = 136 182 | F7 = 137 183 | F8 = 138 184 | F9 = 139 185 | F10 = 140 186 | F11 = 141 187 | F12 = 142 188 | NUM_LOCK = 143 189 | NUMPAD_0 = 144 190 | NUMPAD_1 = 145 191 | NUMPAD_2 = 146 192 | NUMPAD_3 = 147 193 | NUMPAD_4 = 148 194 | NUMPAD_5 = 149 195 | NUMPAD_6 = 150 196 | NUMPAD_7 = 151 197 | NUMPAD_8 = 152 198 | NUMPAD_9 = 153 199 | NUMPAD_DIVIDE = 154 200 | NUMPAD_MULTIPLY = 155 201 | NUMPAD_SUBTRACT = 156 202 | NUMPAD_ADD = 157 203 | NUMPAD_DOT = 158 204 | NUMPAD_COMMA = 159 205 | NUMPAD_ENTER = 160 206 | NUMPAD_EQUALS = 161 207 | NUMPAD_LEFT_PAREN = 162 208 | NUMPAD_RIGHT_PAREN = 163 209 | VOLUME_MUTE = 164 210 | INFO = 165 211 | CHANNEL_UP = 166 212 | CHANNEL_DOWN = 167 213 | ZOOM_IN = 168 214 | ZOOM_OUT = 169 215 | TV = 170 216 | WINDOW = 171 217 | GUIDE = 172 218 | DVR = 173 219 | BOOKMARK = 174 220 | CAPTIONS = 175 221 | SETTINGS = 176 222 | TV_POWER = 177 223 | TV_INPUT = 178 224 | STB_POWER = 179 225 | STB_INPUT = 180 226 | AVR_POWER = 181 227 | AVR_INPUT = 182 228 | PROG_RED = 183 229 | PROG_GREEN = 184 230 | PROG_YELLOW = 185 231 | PROG_BLUE = 186 232 | APP_SWITCH = 187 233 | BUTTON_1 = 188 234 | BUTTON_2 = 189 235 | BUTTON_3 = 190 236 | BUTTON_4 = 191 237 | BUTTON_5 = 192 238 | BUTTON_6 = 193 239 | BUTTON_7 = 194 240 | BUTTON_8 = 195 241 | BUTTON_9 = 196 242 | BUTTON_10 = 197 243 | BUTTON_11 = 198 244 | BUTTON_12 = 199 245 | BUTTON_13 = 200 246 | BUTTON_14 = 201 247 | BUTTON_15 = 202 248 | BUTTON_16 = 203 249 | LANGUAGE_SWITCH = 204 250 | MANNER_MODE = 205 251 | MODE_3D = 206 252 | CONTACTS = 207 253 | CALENDAR = 208 254 | MUSIC = 209 255 | CALCULATOR = 210 256 | ZENKAKU_HANKAKU = 211 257 | EISU = 212 258 | MUHENKAN = 213 259 | HENKAN = 214 260 | KATAKANA_HIRAGANA = 215 261 | YEN = 216 262 | RO = 217 263 | KANA = 218 264 | ASSIST = 219 265 | BRIGHTNESS_DOWN = 220 266 | BRIGHTNESS_UP = 221 267 | MEDIA_AUDIO_TRACK = 222 268 | SLEEP = 223 269 | WAKEUP = 224 270 | PAIRING = 225 271 | MEDIA_TOP_MENU = 226 272 | KEY_11 = 227 273 | KEY_12 = 228 274 | LAST_CHANNEL = 229 275 | TV_DATA_SERVICE = 230 276 | VOICE_ASSIST = 231 277 | TV_RADIO_SERVICE = 232 278 | TV_TELETEXT = 233 279 | TV_NUMBER_ENTRY = 234 280 | TV_TERRESTRIAL_ANALOG = 235 281 | TV_TERRESTRIAL_DIGITAL = 236 282 | TV_SATELLITE = 237 283 | TV_SATELLITE_BS = 238 284 | TV_SATELLITE_CS = 239 285 | TV_SATELLITE_SERVICE = 240 286 | TV_NETWORK = 241 287 | TV_ANTENNA_CABLE = 242 288 | TV_INPUT_HDMI_1 = 243 289 | TV_INPUT_HDMI_2 = 244 290 | TV_INPUT_HDMI_3 = 245 291 | TV_INPUT_HDMI_4 = 246 292 | TV_INPUT_COMPOSITE_1 = 247 293 | TV_INPUT_COMPOSITE_2 = 248 294 | TV_INPUT_COMPONENT_1 = 249 295 | TV_INPUT_COMPONENT_2 = 250 296 | TV_INPUT_VGA_1 = 251 297 | TV_AUDIO_DESCRIPTION = 252 298 | TV_AUDIO_DESCRIPTION_MIX_UP = 253 299 | TV_AUDIO_DESCRIPTION_MIX_DOWN = 254 300 | TV_ZOOM_MODE = 255 301 | TV_CONTENTS_MENU = 256 302 | TV_MEDIA_CONTEXT_MENU = 257 303 | TV_TIMER_PROGRAMMING = 258 304 | HELP = 259 305 | NAVIGATE_PREVIOUS = 260 306 | NAVIGATE_NEXT = 261 307 | NAVIGATE_IN = 262 308 | NAVIGATE_OUT = 263 309 | STEM_PRIMARY = 264 310 | STEM_1 = 265 311 | STEM_2 = 266 312 | STEM_3 = 267 313 | DPAD_UP_LEFT = 268 314 | DPAD_DOWN_LEFT = 269 315 | DPAD_UP_RIGHT = 270 316 | DPAD_DOWN_RIGHT = 271 317 | MEDIA_SKIP_FORWARD = 272 318 | MEDIA_SKIP_BACKWARD = 273 319 | MEDIA_STEP_FORWARD = 274 320 | MEDIA_STEP_BACKWARD = 275 321 | SOFT_SLEEP = 276 322 | CUT = 277 323 | COPY = 278 324 | PASTE = 279 325 | SYSTEM_NAVIGATION_UP = 280 326 | SYSTEM_NAVIGATION_DOWN = 281 327 | SYSTEM_NAVIGATION_LEFT = 282 328 | SYSTEM_NAVIGATION_RIGHT = 283 329 | ALL_APPS = 284 330 | 331 | # ========================================================================= 332 | # Aliases for original Android KeyEvent names 333 | # ========================================================================= 334 | # These aliases are provided to maintain compatibility with the original 335 | # Android KeyEvent naming convention (AKEYCODE_*). This makes it easier 336 | # to reference keys using the same names as in Android documentation. 337 | 338 | # Numeric key aliases 339 | KEYCODE_0 = KEY_0 # '0' key 340 | KEYCODE_1 = KEY_1 # '1' key 341 | KEYCODE_2 = KEY_2 # '2' key 342 | KEYCODE_3 = KEY_3 # '3' key 343 | KEYCODE_4 = KEY_4 # '4' key 344 | KEYCODE_5 = KEY_5 # '5' key 345 | KEYCODE_6 = KEY_6 # '6' key 346 | KEYCODE_7 = KEY_7 # '7' key 347 | KEYCODE_8 = KEY_8 # '8' key 348 | KEYCODE_9 = KEY_9 # '9' key 349 | KEYCODE_11 = KEY_11 # '11' key 350 | KEYCODE_12 = KEY_12 # '12' key -------------------------------------------------------------------------------- /uiautodev/driver/harmony.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import json 4 | import logging 5 | import os 6 | import re 7 | import shutil 8 | import subprocess 9 | import tempfile 10 | import time 11 | import uuid 12 | from pathlib import Path 13 | from typing import Dict, List, Optional, Tuple, Union, final 14 | 15 | from PIL import Image 16 | 17 | from uiautodev.command_types import CurrentAppResponse 18 | from uiautodev.driver.base_driver import BaseDriver 19 | from uiautodev.model import AppInfo, Node, Rect, ShellResponse, WindowSize 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | StrOrPath = Union[str, Path] 24 | 25 | 26 | def run_command(command: str, timeout: int = 60) -> str: 27 | try: 28 | result = subprocess.run( 29 | command, 30 | shell=True, 31 | capture_output=True, 32 | timeout=timeout, 33 | text=True, 34 | errors='ignore', 35 | input='' # this avoid stdout: "FreeChannelContinue handle->data is nullptr" 36 | ) 37 | # the hdc shell stderr is (不仅没啥用,还没办法去掉) 38 | # Remote PTY will not be allocated because stdin is not a terminal. 39 | # Use multiple -t options to force remote PTY allocation. 40 | output = result.stdout.strip() 41 | return output 42 | except subprocess.TimeoutExpired as e: 43 | raise TimeoutError(f"{command:r} timeout {e}") 44 | 45 | 46 | class HDCError(Exception): 47 | pass 48 | 49 | 50 | class HDC: 51 | def __init__(self): 52 | self.hdc = 'hdc' 53 | self.tmpdir = tempfile.TemporaryDirectory() 54 | 55 | def __del__(self): 56 | self.tmpdir.cleanup() 57 | 58 | def list_device(self) -> List[str]: 59 | command = f"{self.hdc} list targets" 60 | result = run_command(command) 61 | if result and not "Empty" in result: 62 | devices = [] 63 | for line in result.strip().split("\n"): 64 | serial = line.strip().split('\t', 1)[0] 65 | devices.append(serial) 66 | return devices 67 | else: 68 | return [] 69 | 70 | def shell(self, serial: str, command: str) -> str: 71 | command = f"{self.hdc} -t {serial} shell \"{command}\"" 72 | result = run_command(command) 73 | return result.strip() 74 | 75 | def __split_text(self, text: str) -> str: 76 | return text.split("\n")[0].strip() if text else "" 77 | 78 | def get_model(self, serial: str) -> str: 79 | return self.shell(serial, "param get const.product.model") 80 | 81 | def get_name(self, serial: str) -> str: 82 | data = self.shell(serial, "param get const.product.name") 83 | return self.__split_text(data) 84 | 85 | def wlan_ip(self, serial: str) -> str: 86 | data = self.shell(serial, "ifconfig") 87 | if not data or "not found" in data.lower() or "error" in data.lower(): 88 | logger.warning(f"ifconfig command failed or returned error for serial {serial}: {data!r}") 89 | return "" 90 | # Try multiple patterns for IP address 91 | matches = re.findall(r'inet addr:(?!127)(\d+\.\d+\.\d+\.\d+)', data) 92 | if not matches: 93 | matches = re.findall(r'inet (?!127)(\d+\.\d+\.\d+\.\d+)', data) 94 | if matches: 95 | return matches[0] 96 | logger.warning(f"No valid IP address found in ifconfig output for serial {serial}: {data!r}") 97 | return "" 98 | 99 | def sdk_version(self, serial: str) -> str: 100 | data = self.shell(serial, "param get const.ohos.apiversion") 101 | return self.__split_text(data) 102 | 103 | def sys_version(self, serial: str) -> str: 104 | data = self.shell(serial, "param get const.product.software.version") 105 | return self.__split_text(data) 106 | 107 | def brand(self, serial: str) -> str: 108 | data = self.shell(serial, "param get const.product.brand") 109 | return self.__split_text(data) 110 | 111 | def pull(self, serial: str, remote: StrOrPath, local: StrOrPath): 112 | if isinstance(remote, Path): 113 | remote = remote.as_posix() 114 | command = f"{self.hdc} -t {serial} file recv {remote} {local}" 115 | output = run_command(command) 116 | if not os.path.exists(local): 117 | raise HDCError(f"device file: {remote} not found", output) 118 | 119 | def push(self, serial: str, local: StrOrPath, remote: StrOrPath) -> str: 120 | if isinstance(remote, Path): 121 | remote = remote.as_posix() 122 | command = f"{self.hdc} -t {serial} file send {local} {remote}" 123 | return run_command(command) 124 | 125 | def screenshot(self, serial: str) -> Image.Image: 126 | device_path = f'/data/local/tmp/screenshot-{int(time.time()*1000)}.png' 127 | self.shell(serial, f"uitest screenCap -p {device_path}") 128 | try: 129 | local_path = os.path.join(self.tmpdir.name, f"{uuid.uuid4()}.png") 130 | self.pull(serial, device_path, local_path) 131 | with Image.open(local_path) as image: 132 | image.load() 133 | return image 134 | finally: 135 | self.shell(serial, f"rm {device_path}") 136 | 137 | def dump_layout(self, serial: str) -> dict: 138 | name = "{}.json".format(int(time.time() * 1000)) 139 | remote_path = f"/data/local/tmp/layout-{name}.json" 140 | temp_path = os.path.join(self.tmpdir.name, f"layout-{name}.json") 141 | output = self.shell(serial, f"uitest dumpLayout -p {remote_path}") 142 | self.pull(serial, remote_path, temp_path) 143 | # mock 144 | # temp_path = Path(__file__).parent / 'testdata/layout.json' 145 | try: 146 | with open(temp_path, "rb") as f: 147 | json_content = json.load(f) 148 | return json_content 149 | except json.JSONDecodeError: 150 | raise HDCError(f"failed to dump layout: {output}") 151 | finally: 152 | self.shell(serial, f"rm {remote_path}") 153 | 154 | 155 | class HarmonyDriver(BaseDriver): 156 | def __init__(self, hdc: HDC, serial: str): 157 | super().__init__(serial) 158 | self.hdc = hdc 159 | 160 | def screenshot(self, id: int = 0) -> Image.Image: 161 | return self.hdc.screenshot(self.serial) 162 | 163 | def window_size(self) -> WindowSize: 164 | result = self.hdc.shell(self.serial, "hidumper -s 10 -a screen") 165 | pattern = r"activeMode:\s*(\d+x\d+)" 166 | match = re.search(pattern, result) 167 | if match: 168 | resolution = match.group(1).split("x") 169 | return WindowSize(width=int(resolution[0]), height=int(resolution[1])) 170 | else: 171 | image = self.screenshot() 172 | return WindowSize(width=image.width, height=image.height) 173 | 174 | def dump_hierarchy(self) -> Tuple[str, Node]: 175 | """returns xml string and hierarchy object""" 176 | layout = self.hdc.dump_layout(self.serial) 177 | return json.dumps(layout), parse_json_element(layout) 178 | 179 | def tap(self, x: int, y: int): 180 | self.hdc.shell(self.serial, f"uinput -T -c {x} {y}") 181 | 182 | def app_current(self) -> Optional[CurrentAppResponse]: 183 | echo = self.hdc.shell(self.serial, "hidumper -s WindowManagerService -a '-a'") 184 | focus_window = re.search(r"Focus window: (\d+)", echo) 185 | if focus_window: 186 | focus_window = focus_window.group(1) 187 | mission_echo = self.hdc.shell(self.serial, "aa dump -a") 188 | pkg_names = re.findall(r"Mission ID #(\d+)\s+mission name #\[(.*?)\]", mission_echo) 189 | if focus_window and pkg_names: 190 | for mission in pkg_names: 191 | mission_id = mission[0] 192 | if focus_window == mission_id: 193 | mission_name = mission[1] 194 | pkg_name = mission_name.split(":")[0].replace("#", "") 195 | ability_name = mission_name.split(":")[-1] 196 | pid = self.hdc.shell(self.serial, f"pidof {pkg_name}").strip() 197 | return CurrentAppResponse(package=pkg_name, activity=ability_name, pid=int(pid)) 198 | else: 199 | return None 200 | 201 | def get_app_info(self, package_name: str) -> Dict: 202 | """ 203 | Get detailed information about a specific application. 204 | 205 | Args: 206 | package_name (str): The package name of the application to retrieve information for. 207 | 208 | Returns: 209 | Dict: A dictionary containing the application information. If an error occurs during parsing, 210 | an empty dictionary is returned. 211 | """ 212 | app_info = {} 213 | data = self.hdc.shell(self.serial, f"bm dump -n {package_name}") 214 | output = data 215 | try: 216 | json_start = output.find("{") 217 | json_end = output.rfind("}") + 1 218 | json_output = output[json_start:json_end] 219 | 220 | app_info = json.loads(json_output) 221 | except Exception as e: 222 | logger.error(f"An error occurred: {e}") 223 | return app_info 224 | 225 | def get_app_abilities(self, package_name: str) -> List[Dict]: 226 | """ 227 | Get the abilities of an application. 228 | 229 | Args: 230 | package_name (str): The package name of the application. 231 | 232 | Returns: 233 | List[Dict]: A list of dictionaries containing the abilities of the application. 234 | """ 235 | result = [] 236 | app_info = self.get_app_info(package_name) 237 | hap_module_infos = app_info.get("hapModuleInfos") 238 | main_entry = app_info.get("mainEntry") 239 | for hap_module_info in hap_module_infos: 240 | # 尝试读取moduleInfo 241 | try: 242 | ability_infos = hap_module_info.get("abilityInfos") 243 | module_main = hap_module_info["mainAbility"] 244 | except Exception as e: 245 | logger.warning(f"Fail to parse moduleInfo item, {repr(e)}") 246 | continue 247 | # 尝试读取abilityInfo 248 | for ability_info in ability_infos: 249 | try: 250 | is_launcher_ability = False 251 | skills = ability_info['skills'] 252 | if len(skills) > 0 and "action.system.home" in skills[0]["actions"]: 253 | is_launcher_ability = True 254 | icon_ability_info = { 255 | "name": ability_info["name"], 256 | "moduleName": ability_info["moduleName"], 257 | "moduleMainAbility": module_main, 258 | "mainModule": main_entry, 259 | "isLauncherAbility": is_launcher_ability 260 | } 261 | result.append(icon_ability_info) 262 | except Exception as e: 263 | logger.warning(f"Fail to parse ability_info item, {repr(e)}") 264 | continue 265 | logger.debug(f"all abilities: {result}") 266 | return result 267 | 268 | def get_app_main_ability(self, package_name: str) -> Dict: 269 | """ 270 | Get the main ability of an application. 271 | 272 | Args: 273 | package_name (str): The package name of the application to retrieve information for. 274 | 275 | Returns: 276 | Dict: A dictionary containing the main ability of the application. 277 | 278 | """ 279 | if not (abilities := self.get_app_abilities(package_name)): 280 | return {} 281 | for item in abilities: 282 | score = 0 283 | if (name := item["name"]) and name == item["moduleMainAbility"]: 284 | score += 1 285 | if (module_name := item["moduleName"]) and module_name == item["mainModule"]: 286 | score += 1 287 | item["score"] = score 288 | abilities.sort(key=lambda x: (not x["isLauncherAbility"], -x["score"])) 289 | logger.debug(f"main ability: {abilities[0]}") 290 | return abilities[0] 291 | 292 | def app_launch(self, package: str, page_name: Optional[str] = None): 293 | """ 294 | Start an application on the device. 295 | If the `page_name` is empty, it will retrieve the main ability using `get_app_main_ability`. 296 | Args: 297 | package (str): The package name of the application. 298 | page_name (Optional[str]): Ability Name within the application to start. If not provided, the main ability will be used. 299 | """ 300 | if not page_name: 301 | page_name = self.get_app_main_ability(package).get('name', 'MainAbility') 302 | self.shell(f"aa start -a {page_name} -b {package}") 303 | 304 | def app_terminate(self, package: str): 305 | self.shell(f"aa force-stop {package}") 306 | 307 | def shell(self, command: str) -> ShellResponse: 308 | result = self.hdc.shell(self.serial, command) 309 | return ShellResponse(output=result) 310 | 311 | def home(self): 312 | self.hdc.shell(self.serial, "uinput -K -d 1 -u 1") 313 | 314 | def back(self): 315 | self.hdc.shell(self.serial, "uinput -K -d 2 -u 2") 316 | 317 | def volume_up(self): 318 | self.hdc.shell(self.serial, "uinput -K -d 16 -u 16") 319 | 320 | def volume_down(self): 321 | self.hdc.shell(self.serial, "uinput -K -d 17 -u 17") 322 | 323 | def volume_mute(self): 324 | self.hdc.shell(self.serial, "uinput -K -d 22 -u 22") 325 | 326 | def app_switch(self): 327 | self.hdc.shell(self.serial, "uinput -K -d 2076 -d 2049 -u 2076 -u 2049") 328 | 329 | def app_list(self) -> List[AppInfo]: 330 | results = [] 331 | output = self.hdc.shell(self.serial, "bm dump -a") 332 | for i in output.split("\n"): 333 | if "ID" in i: 334 | continue 335 | else: 336 | results.append(AppInfo(packageName=i.strip())) 337 | return results 338 | 339 | 340 | def parse_json_element(element, indexes: List[int] = [0]) -> Node: 341 | """ 342 | Recursively parse an json element into a dictionary format. 343 | """ 344 | attributes = element.get("attributes", {}) 345 | name = attributes.get("type", "") 346 | bounds = attributes.get("bounds", "") 347 | bounds = list(map(int, re.findall(r"\d+", bounds))) 348 | assert len(bounds) == 4 349 | rect = Rect(x=bounds[0], y=bounds[1], width=bounds[2] - bounds[0], height=bounds[3] - bounds[1]) 350 | elem = Node( 351 | key="-".join(map(str, indexes)), 352 | name=name, 353 | bounds=None, 354 | rect=rect, 355 | properties={key: attributes[key] for key in attributes}, 356 | children=[], 357 | ) 358 | # Construct xpath for children 359 | for index, child in enumerate(element.get("children", [])): 360 | child_node = parse_json_element(child, indexes + [index]) 361 | if child_node: 362 | elem.children.append(child_node) 363 | 364 | return elem 365 | -------------------------------------------------------------------------------- /uiautodev/utils/usbmux.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copy from https://github.com/doronz88/pymobiledevice3 3 | 4 | Add http.client.HTTPConnection 5 | """ 6 | import abc 7 | import plistlib 8 | import socket 9 | import sys 10 | import time 11 | from dataclasses import dataclass 12 | from http.client import HTTPConnection 13 | from typing import List, Mapping, Optional 14 | 15 | from construct import Const, CString, Enum, FixedSized, GreedyBytes, Int16ul, Int32ul, Padding, Prefixed, StreamError, \ 16 | Struct, Switch, this 17 | 18 | from uiautodev.utils.exceptions import BadCommandError, BadDevError, ConnectionFailedError, \ 19 | ConnectionFailedToUsbmuxdError, MuxException, MuxVersionError, NotPairedError 20 | 21 | usbmuxd_version = Enum(Int32ul, 22 | BINARY=0, 23 | PLIST=1, 24 | ) 25 | 26 | usbmuxd_result = Enum(Int32ul, 27 | OK=0, 28 | BADCOMMAND=1, 29 | BADDEV=2, 30 | CONNREFUSED=3, 31 | BADVERSION=6, 32 | ) 33 | 34 | usbmuxd_msgtype = Enum(Int32ul, 35 | RESULT=1, 36 | CONNECT=2, 37 | LISTEN=3, 38 | ADD=4, 39 | REMOVE=5, 40 | PAIRED=6, 41 | PLIST=8, 42 | ) 43 | 44 | usbmuxd_header = Struct( 45 | 'version' / usbmuxd_version, # protocol version 46 | 'message' / usbmuxd_msgtype, # message type 47 | 'tag' / Int32ul, # responses to this query will echo back this tag 48 | ) 49 | 50 | usbmuxd_request = Prefixed(Int32ul, Struct( 51 | 'header' / usbmuxd_header, 52 | 'data' / Switch(this.header.message, { 53 | usbmuxd_msgtype.CONNECT: Struct( 54 | 'device_id' / Int32ul, 55 | 'port' / Int16ul, # TCP port number 56 | 'reserved' / Const(0, Int16ul), 57 | ), 58 | usbmuxd_msgtype.PLIST: GreedyBytes, 59 | }), 60 | ), includelength=True) 61 | 62 | usbmuxd_device_record = Struct( 63 | 'device_id' / Int32ul, 64 | 'product_id' / Int16ul, 65 | 'serial_number' / FixedSized(256, CString('ascii')), 66 | Padding(2), 67 | 'location' / Int32ul 68 | ) 69 | 70 | usbmuxd_response = Prefixed(Int32ul, Struct( 71 | 'header' / usbmuxd_header, 72 | 'data' / Switch(this.header.message, { 73 | usbmuxd_msgtype.RESULT: Struct( 74 | 'result' / usbmuxd_result, 75 | ), 76 | usbmuxd_msgtype.ADD: usbmuxd_device_record, 77 | usbmuxd_msgtype.REMOVE: Struct( 78 | 'device_id' / Int32ul, 79 | ), 80 | usbmuxd_msgtype.PLIST: GreedyBytes, 81 | }), 82 | ), includelength=True) 83 | 84 | 85 | 86 | 87 | @dataclass 88 | class MuxDevice: 89 | devid: int 90 | serial: str 91 | connection_type: str 92 | 93 | def connect(self, port: int, usbmux_address: Optional[str] = None) -> socket.socket: 94 | mux = create_mux(usbmux_address=usbmux_address) 95 | try: 96 | return mux.connect(self, port) 97 | except: # noqa: E722 98 | mux.close() 99 | raise 100 | 101 | @property 102 | def is_usb(self) -> bool: 103 | return self.connection_type == 'USB' 104 | 105 | @property 106 | def is_network(self) -> bool: 107 | return self.connection_type == 'Network' 108 | 109 | def matches_udid(self, udid: str) -> bool: 110 | return self.serial.replace('-', '') == udid.replace('-', '') 111 | 112 | def make_http_connection(self, port: int) -> HTTPConnection: 113 | return USBMuxHTTPConnection(self, port) 114 | 115 | 116 | class SafeStreamSocket: 117 | """ wrapper to native python socket object to be used with construct as a stream """ 118 | 119 | def __init__(self, address, family): 120 | self._offset = 0 121 | self.sock = socket.socket(family, socket.SOCK_STREAM) 122 | self.sock.connect(address) 123 | 124 | def send(self, msg: bytes) -> int: 125 | self._offset += len(msg) 126 | self.sock.sendall(msg) 127 | return len(msg) 128 | 129 | def recv(self, size: int) -> bytes: 130 | msg = b'' 131 | while len(msg) < size: 132 | chunk = self.sock.recv(size - len(msg)) 133 | self._offset += len(chunk) 134 | if not chunk: 135 | raise MuxException('socket connection broken') 136 | msg += chunk 137 | return msg 138 | 139 | def close(self) -> None: 140 | self.sock.close() 141 | 142 | def settimeout(self, interval: float) -> None: 143 | self.sock.settimeout(interval) 144 | 145 | def setblocking(self, blocking: bool) -> None: 146 | self.sock.setblocking(blocking) 147 | 148 | def tell(self) -> int: 149 | return self._offset 150 | 151 | read = recv 152 | write = send 153 | 154 | 155 | class MuxConnection: 156 | # used on Windows 157 | ITUNES_HOST = ('127.0.0.1', 27015) 158 | 159 | # used for macOS and Linux 160 | USBMUXD_PIPE = '/var/run/usbmuxd' 161 | 162 | @staticmethod 163 | def create_usbmux_socket(usbmux_address: Optional[str] = None) -> SafeStreamSocket: 164 | try: 165 | if usbmux_address is not None: 166 | if ':' in usbmux_address: 167 | # assume tcp address 168 | hostname, port = usbmux_address.split(':') 169 | port = int(port) 170 | address = (hostname, port) 171 | family = socket.AF_INET 172 | else: 173 | # assume unix domain address 174 | address = usbmux_address 175 | family = socket.AF_UNIX 176 | else: 177 | if sys.platform in ['win32', 'cygwin']: 178 | address = MuxConnection.ITUNES_HOST 179 | family = socket.AF_INET 180 | else: 181 | address = MuxConnection.USBMUXD_PIPE 182 | family = socket.AF_UNIX 183 | return SafeStreamSocket(address, family) 184 | except ConnectionRefusedError: 185 | raise ConnectionFailedToUsbmuxdError() 186 | 187 | @staticmethod 188 | def create(usbmux_address: Optional[str] = None): 189 | # first attempt to connect with possibly the wrong version header (plist protocol) 190 | sock = MuxConnection.create_usbmux_socket(usbmux_address=usbmux_address) 191 | 192 | message = usbmuxd_request.build({ 193 | 'header': {'version': usbmuxd_version.PLIST, 'message': usbmuxd_msgtype.PLIST, 'tag': 1}, 194 | 'data': plistlib.dumps({'MessageType': 'ReadBUID'}) 195 | }) 196 | sock.send(message) 197 | response = usbmuxd_response.parse_stream(sock) 198 | 199 | # if we sent a bad request, we should re-create the socket in the correct version this time 200 | sock.close() 201 | sock = MuxConnection.create_usbmux_socket(usbmux_address=usbmux_address) 202 | 203 | if response.header.version == usbmuxd_version.BINARY: 204 | return BinaryMuxConnection(sock) 205 | elif response.header.version == usbmuxd_version.PLIST: 206 | return PlistMuxConnection(sock) 207 | 208 | raise MuxVersionError(f'usbmuxd returned unsupported version: {response.version}') 209 | 210 | def __init__(self, sock: SafeStreamSocket): 211 | self._sock = sock 212 | 213 | # after initiating the "Connect" packet, this same socket will be used to transfer data into the service 214 | # residing inside the target device. when this happens, we can no longer send/receive control commands to 215 | # usbmux on same socket 216 | self._connected = False 217 | 218 | # message sequence number. used when verifying the response matched the request 219 | self._tag = 1 220 | 221 | self.devices = [] 222 | 223 | @abc.abstractmethod 224 | def _connect(self, device_id: int, port: int): 225 | """ initiate a "Connect" request to target port """ 226 | pass 227 | 228 | @abc.abstractmethod 229 | def get_device_list(self, timeout: float = None): 230 | """ 231 | request an update to current device list 232 | """ 233 | pass 234 | 235 | def connect(self, device: MuxDevice, port: int) -> socket.socket: 236 | """ connect to a relay port on target machine and get a raw python socket object for the connection """ 237 | self._connect(device.devid, socket.htons(port)) 238 | self._connected = True 239 | return self._sock.sock 240 | 241 | def close(self): 242 | """ close current socket """ 243 | self._sock.close() 244 | 245 | def _assert_not_connected(self): 246 | """ verify active state is in state for control messages """ 247 | if self._connected: 248 | raise MuxException('Mux is connected, cannot issue control packets') 249 | 250 | def _raise_mux_exception(self, result: int, message: str = None): 251 | exceptions = { 252 | int(usbmuxd_result.BADCOMMAND): BadCommandError, 253 | int(usbmuxd_result.BADDEV): BadDevError, 254 | int(usbmuxd_result.CONNREFUSED): ConnectionFailedError, 255 | int(usbmuxd_result.BADVERSION): MuxVersionError, 256 | } 257 | exception = exceptions.get(result, MuxException) 258 | raise exception(message) 259 | 260 | def __enter__(self): 261 | return self 262 | 263 | def __exit__(self, exc_type, exc_val, exc_tb): 264 | self.close() 265 | 266 | 267 | class BinaryMuxConnection(MuxConnection): 268 | """ old binary protocol """ 269 | 270 | def __init__(self, sock: SafeStreamSocket): 271 | super().__init__(sock) 272 | self._version = usbmuxd_version.BINARY 273 | 274 | def get_device_list(self, timeout: float = None): 275 | """ use timeout to wait for the device list to be fully populated """ 276 | self._assert_not_connected() 277 | end = time.time() + timeout 278 | self.listen() 279 | while time.time() < end: 280 | self._sock.settimeout(end - time.time()) 281 | try: 282 | self._receive_device_state_update() 283 | except (BlockingIOError, StreamError): 284 | continue 285 | except IOError: 286 | try: 287 | self._sock.setblocking(True) 288 | self.close() 289 | except OSError: 290 | pass 291 | raise MuxException('Exception in listener socket') 292 | 293 | def listen(self): 294 | """ start listening for events of attached and detached devices """ 295 | self._send_receive(usbmuxd_msgtype.LISTEN) 296 | 297 | def _connect(self, device_id: int, port: int): 298 | self._send({'header': {'version': self._version, 299 | 'message': usbmuxd_msgtype.CONNECT, 300 | 'tag': self._tag}, 301 | 'data': {'device_id': device_id, 'port': port}, 302 | }) 303 | response = self._receive() 304 | if response.header.message != usbmuxd_msgtype.RESULT: 305 | raise MuxException(f'unexpected message type received: {response}') 306 | 307 | if response.data.result != usbmuxd_result.OK: 308 | raise self._raise_mux_exception(int(response.data.result), 309 | f'failed to connect to device: {device_id} at port: {port}. reason: ' 310 | f'{response.data.result}') 311 | 312 | def _send(self, data: Mapping): 313 | self._assert_not_connected() 314 | self._sock.send(usbmuxd_request.build(data)) 315 | self._tag += 1 316 | 317 | def _receive(self, expected_tag: int = None): 318 | self._assert_not_connected() 319 | response = usbmuxd_response.parse_stream(self._sock) 320 | if expected_tag and response.header.tag != expected_tag: 321 | raise MuxException(f'Reply tag mismatch: expected {expected_tag}, got {response.header.tag}') 322 | return response 323 | 324 | def _send_receive(self, message_type: int): 325 | self._send({'header': {'version': self._version, 'message': message_type, 'tag': self._tag}, 326 | 'data': b''}) 327 | response = self._receive(self._tag - 1) 328 | if response.header.message != usbmuxd_msgtype.RESULT: 329 | raise MuxException(f'unexpected message type received: {response}') 330 | 331 | result = response.data.result 332 | if result != usbmuxd_result.OK: 333 | raise self._raise_mux_exception(int(result), f'{message_type} failed: error {result}') 334 | 335 | def _add_device(self, device: MuxDevice): 336 | self.devices.append(device) 337 | 338 | def _remove_device(self, device_id: int): 339 | self.devices = [device for device in self.devices if device.devid != device_id] 340 | 341 | def _receive_device_state_update(self): 342 | response = self._receive() 343 | if response.header.message == usbmuxd_msgtype.ADD: 344 | # old protocol only supported USB devices 345 | self._add_device(MuxDevice(response.data.device_id, response.data.serial_number, 'USB')) 346 | elif response.header.message == usbmuxd_msgtype.REMOVE: 347 | self._remove_device(response.data.device_id) 348 | else: 349 | raise MuxException(f'Invalid packet type received: {response}') 350 | 351 | 352 | class PlistMuxConnection(BinaryMuxConnection): 353 | def __init__(self, sock: SafeStreamSocket): 354 | super().__init__(sock) 355 | self._version = usbmuxd_version.PLIST 356 | 357 | def listen(self) -> None: 358 | self._send_receive({'MessageType': 'Listen'}) 359 | 360 | def get_pair_record(self, serial: str) -> Mapping: 361 | # serials are saved inside usbmuxd without '-' 362 | self._send({'MessageType': 'ReadPairRecord', 'PairRecordID': serial}) 363 | response = self._receive(self._tag - 1) 364 | pair_record = response.get('PairRecordData') 365 | if pair_record is None: 366 | raise NotPairedError('device should be paired first') 367 | return plistlib.loads(pair_record) 368 | 369 | def get_device_list(self, timeout: float = None) -> None: 370 | """ get device list synchronously without waiting the timeout """ 371 | self.devices = [] 372 | self._send({'MessageType': 'ListDevices'}) 373 | for response in self._receive(self._tag - 1)['DeviceList']: 374 | if response['MessageType'] == 'Attached': 375 | super()._add_device(MuxDevice(response['DeviceID'], response['Properties']['SerialNumber'], 376 | response['Properties']['ConnectionType'])) 377 | elif response['MessageType'] == 'Detached': 378 | super()._remove_device(response['DeviceID']) 379 | else: 380 | raise MuxException(f'Invalid packet type received: {response}') 381 | 382 | def get_buid(self) -> str: 383 | """ get SystemBUID """ 384 | self._send({'MessageType': 'ReadBUID'}) 385 | return self._receive(self._tag - 1)['BUID'] 386 | 387 | def save_pair_record(self, serial: str, device_id: int, record_data: bytes): 388 | # serials are saved inside usbmuxd without '-' 389 | self._send_receive({'MessageType': 'SavePairRecord', 390 | 'PairRecordID': serial, 391 | 'PairRecordData': record_data, 392 | 'DeviceID': device_id}) 393 | 394 | def _connect(self, device_id: int, port: int): 395 | self._send_receive({'MessageType': 'Connect', 'DeviceID': device_id, 'PortNumber': port}) 396 | 397 | def _send(self, data: Mapping): 398 | request = {'ClientVersionString': 'qt4i-usbmuxd', 'ProgName': 'pymobiledevice3', 'kLibUSBMuxVersion': 3} 399 | request.update(data) 400 | super()._send({'header': {'version': self._version, 401 | 'message': usbmuxd_msgtype.PLIST, 402 | 'tag': self._tag}, 403 | 'data': plistlib.dumps(request), 404 | }) 405 | 406 | def _receive(self, expected_tag: int = None) -> Mapping: 407 | response = super()._receive(expected_tag=expected_tag) 408 | if response.header.message != usbmuxd_msgtype.PLIST: 409 | raise MuxException(f'Received non-plist type {response}') 410 | return plistlib.loads(response.data) 411 | 412 | def _send_receive(self, data: Mapping): 413 | self._send(data) 414 | response = self._receive(self._tag - 1) 415 | if response['MessageType'] != 'Result': 416 | raise MuxException(f'got an invalid message: {response}') 417 | if response['Number'] != 0: 418 | raise self._raise_mux_exception(response['Number'], f'got an error message: {response}') 419 | 420 | 421 | def create_mux(usbmux_address: Optional[str] = None) -> MuxConnection: 422 | return MuxConnection.create(usbmux_address=usbmux_address) 423 | 424 | 425 | def list_devices(usbmux_address: Optional[str] = None) -> List[MuxDevice]: 426 | mux = create_mux(usbmux_address=usbmux_address) 427 | mux.get_device_list(0.1) 428 | devices = mux.devices 429 | mux.close() 430 | return devices 431 | 432 | 433 | def select_device(udid: str = None, connection_type: str = None, usbmux_address: Optional[str] = None) \ 434 | -> Optional[MuxDevice]: 435 | """ 436 | select a UsbMux device according to given arguments. 437 | if more than one device could be selected, always prefer the usb one. 438 | """ 439 | tmp = None 440 | for device in list_devices(usbmux_address=usbmux_address): 441 | if connection_type is not None and device.connection_type != connection_type: 442 | # if a specific connection_type was desired and not of this one then skip 443 | continue 444 | 445 | if udid is not None and not device.matches_udid(udid): 446 | # if a specific udid was desired and not of this one then skip 447 | continue 448 | 449 | # save best result as a temporary 450 | tmp = device 451 | 452 | if device.is_usb: 453 | # always prefer usb connection 454 | return device 455 | 456 | return tmp 457 | 458 | 459 | def select_devices_by_connection_type(connection_type: str, usbmux_address: Optional[str] = None) -> List[MuxDevice]: 460 | """ 461 | select all UsbMux devices by connection type 462 | """ 463 | tmp = [] 464 | for device in list_devices(usbmux_address=usbmux_address): 465 | if device.connection_type == connection_type: 466 | tmp.append(device) 467 | 468 | return tmp 469 | 470 | 471 | 472 | class USBMuxHTTPConnection(HTTPConnection): 473 | def __init__(self, device: MuxDevice, port=8100): 474 | super().__init__("localhost", port) 475 | self.__device = device 476 | self.__port = port 477 | 478 | def connect(self): 479 | self.sock = self.__device.connect(self.__port) 480 | 481 | def __enter__(self) -> HTTPConnection: 482 | return self 483 | 484 | def __exit__(self, exc_type, exc_value, traceback): 485 | self.close() --------------------------------------------------------------------------------