Repository: openatx/uiautomator2 Branch: master Commit: e53484d6898a Files: 114 Total size: 479.6 KB Directory structure: gitextract_lso4f4th/ ├── .coveragerc ├── .github/ │ ├── ISSUE_TEMPLATE.md │ ├── copilot-instructions.md │ └── workflows/ │ ├── main.yml │ └── release.yml ├── .gitignore ├── CHANGELOG ├── DEVELOP.md ├── HISTORY.md ├── LICENSE ├── Makefile ├── QUICK_REFERENCE.md ├── README.md ├── README_CN.md ├── XPATH.md ├── XPATH_CN.md ├── _archived/ │ ├── aircv/ │ │ ├── README.md │ │ └── __init__.py │ ├── init.py │ ├── messagebox.py │ ├── ocr/ │ │ ├── README.md │ │ ├── __init__.py │ │ └── baiduOCR.py │ ├── webview.py │ └── widget.py ├── demo_tests/ │ ├── conftest.py │ ├── test_app.py │ ├── test_core.py │ ├── test_device.py │ ├── test_input.py │ ├── test_selector.py │ └── test_watcher.py ├── docs/ │ ├── 2to3.md │ ├── Makefile │ └── conf.py ├── examples/ │ ├── adbkit-init/ │ │ ├── README.md │ │ ├── main.js │ │ └── package.json │ ├── apk_install.py │ ├── batteryweb/ │ │ ├── README.md │ │ ├── main.py │ │ └── templates/ │ │ └── index.html │ ├── com.codeskyblue.remotecamera/ │ │ └── main_test.py │ ├── com.netease.cloudmusic/ │ │ ├── README.txt │ │ └── main.py │ ├── minitouch.py │ ├── multi-thread-example.py │ ├── runyaml/ │ │ ├── run.py │ │ └── test.yml │ ├── test_simple_example.py │ └── u2iniit-standalone/ │ ├── README.txt │ ├── init-vendor.sh │ ├── main.go │ ├── proxyhttp.go │ └── uiautomator2-init-standalone.bat ├── mobile_tests/ │ ├── conftest.py │ ├── runtest.sh │ ├── skip_test_image.py │ ├── test_push_pull.py │ ├── test_screenrecord.py │ ├── test_session.py │ ├── test_settings.py │ ├── test_simple.py │ ├── test_swipe.py │ ├── test_watcher.py │ └── test_xpath.py ├── poetry.toml ├── pyproject.toml ├── tests/ │ ├── test_core.py │ ├── test_import.py │ ├── test_input.py │ ├── test_logger.py │ ├── test_settings.py │ ├── test_utils.py │ └── test_xpath.py ├── uiautomator2/ │ ├── __init__.py │ ├── __main__.py │ ├── _input.py │ ├── _proto.py │ ├── _selector.py │ ├── abstract.py │ ├── assets/ │ │ ├── .gitignore │ │ └── sync.sh │ ├── base.py │ ├── core.py │ ├── exceptions.py │ ├── ext/ │ │ ├── __init__.py │ │ ├── htmlreport/ │ │ │ ├── README.md │ │ │ ├── __init__.py │ │ │ └── assets/ │ │ │ ├── index.html │ │ │ ├── simplehttpserver.py │ │ │ └── start.bat │ │ ├── info/ │ │ │ ├── __init__.py │ │ │ └── conf.py │ │ └── perf/ │ │ ├── README.md │ │ └── __init__.py │ ├── image.py │ ├── screenrecord.py │ ├── settings.py │ ├── swipe.py │ ├── utils.py │ ├── version.py │ ├── watcher.py │ └── xpath.py └── uibox/ ├── LICENSE ├── Makefile ├── README.md ├── cmd/ │ ├── httpcheck.go │ ├── nohup.go │ └── root.go ├── go.mod ├── go.sum ├── go.work └── main.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .coveragerc ================================================ [run] branch = True omit = /tests/** /docs/* /*_tests/** [report] ; Regexes for lines to exclude from consideration exclude_also = ; Don't complain about missing debug-only code: def __repr__ if self\.debug ; Don't complain if tests don't hit defensive assertion code: raise AssertionError raise NotImplementedError ; Don't complain if non-runnable code isn't run: if 0: if __name__ == .__main__.: ; Don't complain about abstract methods, they aren't run: @(abc\.)?abstractmethod except adbutils.AdbError @deprecated ignore_errors = True ================================================ FILE: .github/ISSUE_TEMPLATE.md ================================================ **看完请删掉该内容** *提Bug需要注意的事项* 请务必提供详细的信息,能够复现你的问题,否则很难帮你解决。没用的Issue将自动被机器人打上`Invalid`标签并且自动关闭!!。 - 手机型号 - uiautomator2的版本号(`pip show uiautomator2`) - 手机截图 - 相关日志(Python控制台错误信息, adb logcat完整信息, atxagent.log日志) - 最好能附上可能复现问题的代码。 ================================================ FILE: .github/copilot-instructions.md ================================================ # uiautomator2 uiautomator2 is a Python library providing a simple, easy-to-use, and stable Android automation framework. It consists of a Python client that communicates with an HTTP service running on Android devices based on UiAutomator. **ALWAYS follow these instructions first and completely. Only fallback to additional search and context gathering if the information in these instructions is incomplete or found to be in error.** Always reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here. ## Working Effectively ### Initial Setup - `pip install poetry` -- Install Poetry dependency manager - `poetry install` -- Install all dependencies in virtual environment. NEVER CANCEL: Takes 3-5 minutes. Set timeout to 8+ minutes. - Poetry will create a virtual environment in `.venv/` directory ### Build and Test Process - `poetry run pytest tests/ -v` -- Run unit tests (25 tests). Takes ~3 seconds. All tests should pass. - `make cov` -- Run coverage tests. Takes ~3 seconds. Should show ~27% coverage. - `make format` -- Format code with isort. Takes ~1 second. ALWAYS run before committing. - `poetry build` -- Build distribution packages. Takes ~5 seconds. Creates wheel and sdist in `dist/`. - `poetry run uiautomator2 version` -- Check CLI functionality. Should output version number. ### Asset Synchronization (Optional) - `make sync` -- Download required APK and JAR assets. FAILS due to network restrictions in sandboxed environment. This is EXPECTED and not required for development. - Asset sync downloads Android APK and u2.jar from external hosts which are blocked in this environment. ### Commands That Will Fail (Expected) - `make test` -- Mobile tests require Android device via ADB. Will fail with "Can't find any android device/emulator" - this is EXPECTED. - `make build` -- Full build with poetry plugin. May fail due to system package conflicts. Use `poetry build` instead. - `make sync` -- Asset download fails due to network restrictions. Not required for core development. ## Validation Scenarios After making changes, ALWAYS run this validation sequence: 1. **Unit Tests**: `poetry run pytest tests/ -v` -- Must pass all 25 tests 2. **Coverage**: `make cov` -- Should complete without errors 3. **Formatting**: `make format` -- Always format before committing 4. **Build**: `poetry build` -- Must complete successfully 5. **CLI Test**: `poetry run uiautomator2 --help` -- Should show help output ### Manual Testing Scenarios - Test version command: `poetry run uiautomator2 version` - Test CLI help: `poetry run uiautomator2 --help` - Verify core imports: `poetry run python -c "import uiautomator2; print('Import successful')"` ## Key Components and Structure ### Core Modules (uiautomator2/) - `__init__.py` -- Main API and connection functions (426 lines, 27% coverage) - `xpath.py` -- XPath selector implementation (411 lines, 62% coverage) - `_selector.py` -- UI element selectors (320 lines, 19% coverage) - `core.py` -- Core device interaction (214 lines, 21% coverage) - `watcher.py` -- Event watchers (212 lines, 20% coverage) ### Test Directories - `tests/` -- Unit tests (25 tests, no device required) - `mobile_tests/` -- Integration tests (30 tests, require Android device) - `demo_tests/` -- Example/demo tests ### Build Configuration - `pyproject.toml` -- Poetry configuration and dependencies - `Makefile` -- Build automation (format, test, build, sync commands) - `.coveragerc` -- Coverage configuration ### Additional Components - `uibox/` -- Go component for Android binary tools (separate build system) ### Documentation - `README.md` -- Main documentation with usage examples - `DEVELOP.md` -- Development setup instructions - `XPATH.md` -- XPath selector documentation - `CHANGELOG` -- Version history ## Common Development Tasks ### Adding New Features 1. Run existing tests to ensure baseline: `poetry run pytest tests/ -v` 2. Implement changes in appropriate module under `uiautomator2/` 3. Add unit tests in `tests/` directory 4. Run tests: `poetry run pytest tests/ -v` 5. Format code: `make format` 6. Check coverage: `make cov` 7. Build to verify: `poetry build` ### Debugging Issues - Enable debug logging: Use `-d` flag with CLI commands - Check import issues: `poetry run python -c "import uiautomator2"` - Device connection issues require actual Android device (expected to fail in this environment) ### Code Style - Uses isort for import sorting with HANGING_INDENT mode and 120 character line length - Coverage requirement: Tests should maintain or improve the ~27% coverage baseline - All code must pass existing unit tests ## Environment Limitations **CANNOT DO (Expected Failures):** - Mobile testing without Android device - Asset synchronization (network blocked) - Full make build (dependency conflicts) **CAN DO:** - Unit testing (tests/ directory) - Code formatting and linting - Building with `poetry build` - CLI testing and development - Core library development ## Time Expectations - **NEVER CANCEL**: Poetry install takes 3-5 minutes. Set timeout to 8+ minutes. - Unit tests: ~2.5 seconds - Coverage tests: ~3.5 seconds - Code formatting: ~0.5 seconds - Poetry build: ~3 seconds - Full validation sequence: ~12 seconds ## Key Commands Reference ```bash # Essential development workflow poetry install # Setup (3-5 min, NEVER CANCEL) poetry run pytest tests/ -v # Unit tests (2.5s) make format # Format (0.5s) make cov # Coverage (3.5s) poetry build # Build (3s) # CLI testing poetry run uiautomator2 version # Version check poetry run uiautomator2 --help # Help system # Known failures (expected in sandboxed environment) make test # Requires Android device make sync # Network blocked make build # Dependency conflicts ``` Always validate changes with the full sequence: tests → format → coverage → build → CLI test. ## Validation Guarantee **Every command in these instructions has been validated to work correctly.** If any command fails unexpectedly: 1. First check that you're in the correct directory: `/path/to/uiautomator2` 2. Ensure Poetry virtual environment is properly set up: `poetry install` 3. Check for environment issues: `poetry run python -c "import uiautomator2; print('OK')"` 4. If problems persist, the issue may be with your environment or changes you've made Expected validation results: - Unit tests: 25 tests should pass - Coverage: Should show ~27% total coverage - Format: Should complete without errors (may show "Skipped N files") - Build: Should create `dist/` directory with wheel and sdist - CLI: Should display help text starting with "usage: uiautomator2" ================================================ FILE: .github/workflows/main.yml ================================================ name: Python application on: push: branches: - master tags-ignore: - '*' pull_request: branches: - master jobs: build-and-publish: name: ${{ matrix.os }} / ${{ matrix.python-version }} runs-on: ${{ matrix.image }} strategy: matrix: os: [Ubuntu, Windows] python-version: ["3.8", "3.11"] include: - os: Ubuntu image: ubuntu-latest - os: Windows image: windows-2022 - os: macOS image: macos-12 fail-fast: false steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Get full Python version id: full-python-version run: echo version=$(python -c "import sys; print('-'.join(str(v) for v in sys.version_info))") >> $GITHUB_OUTPUT - name: Update PATH if: ${{ matrix.os != 'Windows' }} run: echo "$HOME/.local/bin" >> $GITHUB_PATH - name: Update Path for Windows if: ${{ matrix.os == 'Windows' }} run: echo "$APPDATA\Python\Scripts" >> $GITHUB_PATH - name: Enable long paths for git on Windows if: ${{ matrix.os == 'Windows' }} # Enable handling long path names (+260 char) on the Windows platform # https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file#maximum-path-length-limitation run: git config --system core.longpaths true - name: Install dependencies run: | python -m pip install --upgrade pip pip install poetry poetry install - name: Run tests with coverage run: | make cov - name: Upload test results to Codecov if: ${{ !cancelled() }} # Run even if tests fail uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v4.0.1 with: token: ${{ secrets.CODECOV_TOKEN }} slug: openatx/uiautomator2 ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: - '*.*.*' jobs: release: name: Release runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: python-version: 3.8 - name: Install dependencies run: | python -m pip install --upgrade pip pip install poetry - name: Build run: | make build - name: Publish distribution 📦 to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: skip-existing: true password: ${{ secrets.PYPI_API_TOKEN }} ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .idea/ .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # dotenv .env # virtualenv .venv venv/ ENV/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ AUTHORS ChangeLog .vscode/ report/ *.apk *.exe node_modules/ vendor/ docs/*.rst .DS_Store *.lock junit.xml ================================================ FILE: CHANGELOG ================================================ CHANGES ======= 2.16.10 ------- * try not to reinstall apk when atx-agent is not installed 2.16.9 ------ * little fix for vivo and oppo, do not reinstall uiautomator apk 2.16.8 ------ * fix dump\_hierarchy error when recovered in a minute * update logic for tmq * fixed: 增加app\_install的超时时间 (#736) 2.16.7 ------ * use filelock to prevent multi process reset\_uiautomator 2.16.6 ------ * remove process\_safe\_wrapper since not allow multi device operation 2.16.5 ------ * use filelock to make process call process safe 2.16.4 ------ * skip uninstall uiautomator apk for tmq platform * add link 2.16.3 ------ * use github actions to publish lib instead of trivis 2.16.2 ------ * fix tests * Update init.py (#618) 2.16.1 ------ * hotfix for multiprocess call reset\_uiautomator * update ISSUE\_TEMPLATE for REQUIRED logs * update doc 2.16.0 ------ * add cli:doctor * add doc 2.15.2 ------ * add support reconnect when device disconnect * update requirements * Update \_\_init\_\_.py (#679) 2.15.1 ------ * try to fix when wifi connect device still try to upgrade atx-agent bug * add multi thread example 2.15.0 ------ * add init --addr support * update func doc 2.14.1 ------ * fix init error 2.14.0 ------ * mark useless tests * add atx-agent version check when something when wrong * update apk and atx-agent version * skip flake8 check 2.13.2 ------ * update atx-agent to fix security error, ref openatx/atx-agent#82 2.13.1 ------ * update minicap download address to devicefarmer group, which support sdk:30 2.13.0 ------ * add d.xpath(..).child support 2.12.3 ------ * show float window in tmq platform 2.12.2 ------ * fix bug #650 * add typing for image, commented findit 2.12.1 ------ * fix d.settings to self.settings * change localhost to 127.0.0.1 2.12.0 ------ * add open\_url method 2.11.5 ------ * fix swipe set duration no effect, close #591 2.11.4 ------ * xpath: %xxx% support content-desc 2.11.3 ------ * add missing builtin arg * add builtin and autostart to watch\_context * add hire doc 2.11.2 ------ * update requirements 2.11.1 ------ * fix settings props check 2.11.0 ------ * add watch\_context which may replace watcher * fix reset-uiautomator on windows error 2.10.2 ------ * add retry for app\_current, fix #572 * update sponsor link 2.10.1 ------ * update tests, prevent atx-agent log too large 2.10.0 ------ * add more tests * add Direction, support scroll\_to, update some doc * d.xpath add scroll support 2.9.6 ----- * fix support for d(resourceId='android:id/text1')[-1].get\_text() 2.9.5 ----- * support change to production use os.environ['TMQ'] = true * raise EnvironmentError directly when connected with wifi, but atx-agent is down 2.9.4 ----- * fix recover logic when atx-agent is not responsing 2.9.3 ----- * enable screenrecord test * fix screenrecord 2.9.2 ----- * fix wait\_for\_device not finished error 2.9.1 ----- * fix selector long\_click bug * update doc 2.9.0 ----- * add operation\_delay support 2.8.6 ----- * add init into connect\_usb for compability 2.8.5 ----- * remove humanize * add support d(description=我的淘宝).screenshot() 2.8.4 ----- * hotfix for set\_new\_command\_timeout error 2.8.3 ----- * hot fix for connect error when atx-agent not installed 2.8.2 ----- * support fallback to WiFi when usb disconnected, add deprecated method :service 2.8.1 ----- * fix app\_start missing stop=True error * support push url 2.8.0 ----- * change property serial back * add double\_click, set click\_pre and post delay to 0 * fix bugs reported in qq * remove useless code * add missing swipe\_ext and @address(teditor) * finally version * add missing toast * add more method * rewrite uiautomator2, too complex 2.7.3 ----- * add timeout(60s) in init.py to prevent hang on apk install page 2.7.2 ----- * update adbutils which buildin adb.exe for windows * rewrite part of init code 2.7.1 ----- * upgrade adbutils: support download adb.exe when missing on windows 2.7.0 ----- * add click\_exists to xpath 2.6.2 ----- * fix with reinstall apks when meet signature not matched error * add image.click doc and tests 2.6.1 ----- * screenrecord support horizontal and vertical, support limit fps * add screenrecord usage 2.6.0 ----- * add screenrecord code * add screenrecord sample 2.5.9 ----- * upgrade atx-agent to 0.9.4 to fix go panic on go12 2.5.8 ----- * update minicap sync method * update atx-agent version and apk version * call watcher when d.xpath calls * let d.touch.down support percent position, remove stop-app when reset-uiautomator * update doc * support Android Q minicap, show debug log when image search 2.5.7 ----- * fix click on infinitly display not working bug * add recommended article * support generate all docs by sphinx * fix docs generate with sphinx, not very well * add missing file * fix retry when take screenshot, update readthedocs * add readthedocs for test 2.5.6 ----- * add match and scroll\_to to xpath object, update atx-agent version 2.5.5 ----- * change connect\_usb not start uiautomator automatically 2.5.4 ----- * update atx-agent and apk version to use minitouchagent 2.5.3 ----- 2.5.2 ----- * fix pull error * add readTimeout handle 2.5.1 ----- * fix \_request func recursive error 2.5.0 ----- * add d.alibaba support * update scale and wait-for-device timeout to 70s * fix when device replugin, d.shell fails 2.4.6 ----- * fix wait am instrument too short, change timeout from 20 to 40 * fix adbutils shell decode error * add retry in push\_url 2.4.5 ----- * fix usb cable replug raise ConnectionError bug 2.4.4 ----- * update apk version, and atx-agent version * update atx-agent to 0.8.1, do lot of code format * fix Android Q screenshot error * fix init may raise FileNotFoundError bug * add uiautomator2 version in command line * add session test 2.4.3 ----- * add fallback and session add some missing method * fix github workflow * fix flake8 warning * test github actions * change callback to fallback * add d.xpath(xxxxx).callback(click, px, py).click() support * add back token again * check if travis notification is working * add d.xpath.position方法 2.4.2 ----- * change am instrument logic again * rewrite jsonrpc\_retry\_call logic * make recover uiautomator logic more simple 2.4.1 ----- * add taobao plugin for internal network * add long\_click to d.xpath 2.4.0 ----- * change logic of start uiautomator, upgrade apk version * fix bug, reported by h.t * am start apk twice to make sure, uiautomator can be recovered 2.3.4 ----- * show lib version when init for easily debug * support config service recover behavior 2.3.3 ----- * fix d.serial return None bug, fix tests on large screen * update doc, add quick-reference.md * add quick ref guide 2.3.2 ----- * fix init command not resolve signature mismatch bug, fix uninstall can not uninstall apk bug 2.3.1 ----- * add xpath\_debug to settings, fix xpath %xx and xx% * update watcher doc 2.3.0 ----- * add d.watcher method to handle popups * add settings code * add basic settings.py * Update README.md * hotfix for windows * remove timeout for function: pull 2.2.0 ----- * add cmd\_purge, add set\_new\_command\_timeout api 2.1.0 ----- * add image.py, change uiautomator from v1 to v2 * add uauto * typo (#476) * fix missing \_parent error, close #477 * hot fix for #475 * fix spell error * fix logo not show error in readme * add hogwarts sponsor * add wait to image.py * fix xpath start-with and ends-with, add image click 2.0.0 ----- * remove toast from readme * add app list api * support multi xpath(xx).xpath(xx), and add .info in xpath * add clipboard doc * change to uiautomator 1.0 * Fixes #451 * add clipboard support * Update README.md * fix d.xpath.when(..).when(..), thread-safe reset-uiautomator 1.3.6 ----- * use monkey command to install apk on TMQ platform * fix d.xpath.watcher, fix d.shell can not handle & and ? bug 1.3.5 ----- * add xpath.apply\_watch\_from\_yaml, support xpath.when(1).when(2) * fix homepage link * fix atx-agent version compare check 1.3.4 ----- * remove useless cli * use jsonrpc.dumpWindowsHierarchy instead of http GET /dump/hierarchy * assert file\_size when cache\_download 1.3.3 ----- * fix uiautomator start error 1.3.2 ----- * update atx-agent to fix UIAutomation not connected error * upgrade apk version * enhance reset\_uiautomator() 1.3.1 ----- * fix adbutils dep version 1.3.0 ----- * fix check atx-agent * fix last commit * add function to check atx-agent version * update atx-agent version * update dingtalk webhook again * update dingtalk webhook 1.2.6 ----- * fix when uiautomator not alive, func connect can not auto init error 1.2.5 ----- * update dingtalk robot webhook url * set init as default, set default screenshot name when use cli:uiautomator2 screenshot * rename current\_app to app\_current * add webview for future develop 1.2.4 ----- * fix app\_start without activity not launch error * add adcd.py(abstract class about device) and implement pure adb to run test * implement pure adb to run test * use Baidu OCR to select element (#419) 1.2.3 ----- * update androidbinary to fix momo can not start error #393 * add support u2.connect\_usb(serial, init=False) * change function behavior d.touch.up() to d.touch.up(x, y) 1.2.2 ----- * fix app\_list\_running() only show 3rd party apps bug, add support to read from env-var ANDROID\_SERIAL 1.2.1 ----- * fix and add doc for app\_start #425, add uiautomator check in dump\_hierarchy * add thread lock in dump\_hierarchy * fix session restart * Update README.md * add notification about dingtalk travis 1.2.0 ----- * add wait gone * add strict argument to session() * rename UIAutomatorServer to Device, add session.restart() method * change http://tool.appetizer.io to https protocol * add swipe\_ext('right', 0.9) method * add app\_wait, app\_list\_running 1.1.0 ----- * add swipe and screenshot to d.xpath element * fix init with serial * update changelog, remove d.watchers.watched, use IPython.embed first in cmd:uiautomator2 console * add console in command line * fix shell(stream=True) timeout error, close #394 1.0.3 ----- * fix android Q support again 1.0.2 ----- * replace google-fire with argparse, add current, stop, start subcommand in command line * remove useless u2cli 1.0.1 ----- * fix init unknown host service, close #373 * add develop.md 1.0.0 ----- * upgrade atx-agent version, and android-uiautomator-version, update doc * fix swipe\_points usage in readme * init add mirror of appetizer * fix str decode error * fix debug mode decode error 0.3.3 ----- * add watch\_clear and address * add xpath.watch\_stop() 0.3.2 ----- * fix debug curl print * fix shell calls in connect 0.3.1 ----- * fix #370 * test with 3.5 0.3.0 ----- * fix fix * fix travis again * fix travis * update readme * add missing dep:adbutils * update xpath doc, add set\_text to xpath * remove uiautomator2/adbutils.py, use thirdparty adbutils * add quickstart, fix healthcheck for OnePlus * fix screenshot method * say goodbye to python2 and welcome python3 * Update ISSUE\_TEMPLATE.md * use /dump/hierarchy to instead of call:dumpHierarchy * update atx-agent version 0.2.3 ----- * xpath element support click * add http\_timeout for shell function, resolve #353 * add xpath quicksheet * resolve #348 * remove code which leads to minicap install error * add get method of xpath * add xpath::get\_text(), close #337 * add connect\_adb\_wifi function * add probot link * auto stale issue when tagged as invalid * serial support none * 修复多台设备时,list-forward失败 (#327) * \`python -m uiautomator2 init\`初始化403报错,增加header atx\_agent\_url中报错变量错误修复 0.2.2 ----- * update atx-agent version * typo (#318) * fix connect\_usb error 0.2.1 ----- * fix #317, fix #316 0.2.0 ----- * merge change * remove pure-python-adb dependency, use adbutils.py instead * format \_\_init\_\_.py, update adbutils with ADB Protocol * update changelog * part of job 0.1.11 ------ * limit pure-python-adb version, to fix from adb.client import error * support args 0.1.10 ------ * remove cmd:init from fire.Fire, fix forward error when muti device connect to one machine * upgrade atx-agent * ext\_xpath support * remove 3.7 * fix travis test again * fix travis * sort imports * split code to different files * Update README.md * Update README.md * remove debug with dict: which will lead misunderstanding * update atx-agent version * appveyor * exedir detection everywhere * fix * come at me * need android components nowadays * travis 2018 switches from android-21 to android-22 * fix pip install requirements * fix travis lang * add emulator and tests to travis and update README * fix typo. (#278) 0.1.9 ----- * fix connect\_usb init error, close #276 * fix typo * add set\_fail\_prompt function * add d.touch.(down|move|up) in readme * fix atxagent version code 0.1.8 ----- * update atx-agent add api app\_info, and app\_icon * update atx-agent version to 0.5.1, fix session timeout error * update atx-agent version and netease music example * add wait\_activity * raise IndexError when UiObject returned by child\_by\_xxx, close #261 * fix xpath py2 py3 compatibale * fix xpath ext resource-id error * Update README.md (#260) * update weditor install method 0.1.7 ----- * sem-ver:bugfix, fix init with PATH env error on windows * fix doc * update apk to 1.1.7 to fix dumpHierarchy, close #207 0.1.6 ----- * use atx-agent server -stop before launch * force stop atx-agent when init * fix launch atx-agent with wrong PATH, which may cause /info get wrong info * fix test on android P emulator * 加入aricv图像识别插件 (#250) * update atx-agent version 0.1.5 ----- * fix init, because of mirror down * fix xpath python2 support, perf create dir if not exists * fix little bug * update readme * first xpath plugin version * add more comment about xpath plugin * add xpath plugin 0.1.4 ----- * update install method * update install part * add install test code * fix fps collect * update atx-agent version * fix if log bug in ext/info * 修改info插件调用模式 (#245) * add test info plugin (#240) * fix perf get data error (#239) * Update README.md * open python 3.7 support * 更改一处类型提示错误 (#229) * add beta method hooks\_register * fix #206, init gives 'inf' as serial (#216) * 修改init不成功的问题 (#221) * update to new atx-agent * fix current\_app in sumsung, add tcp and udp in perf * add images * add fps * swipe duration default 0.1(old 0.5), add swipe ui * fix perf uiautomator in python2 * update doc * fix perf d not exists bug * add traffic into perf plugin * update atx-agent version * catch AttributeError in UIAutomatorServer * add back implicitly wait * add perf doc * add perf plugin * runyaml fix * add plugin\_register and ocr plugin * add plugin support * let shell return namedtuple, remove outdated docs * use q|query instead of xpath in steps * add send\_action support * fix #200 * add with into session, update oppo support * fix merge conflict * click add offset, support oppo install with browser * add oppo install method, not finished yet * fix str(err.data) encode error * Update \_\_init\_\_.py * add some comment * 1.修改截图定位线 * raise error when error found in uiautomator2.cli install * catch NullPointerExceptionError on jsonrpc call * patch to catch UiAutomation not connect * use github-mirror for update-apk command * fix healthcheck * add unlock screen for healthcheck * add retry for objInfo * fix conflict * hot fix for update\_instance * add implicit\_wait function * remove pid file when stop atx-agent 0.1.3 ----- * fix init twice error, update atx-agent t0 0.4.1 * support vivo install * add cancel request support * fix python requires * update to new version * exclude py 3.7 version * make u2cli work * fix when no progress * update uiautomator2.cli install * show progress * add missing file * add u2cli entry * add qrcode of qq * add fail reason * todo: add push folder support * add --mirror document, ref #173 * add retry for dump\_hierarchy, because of UiDevice NullPointer Exception * support github-mirror to make download faster * chmod +x report bad mode on xiaomi HMNote3 * Change method of detecting executable dir * merge openatx * fix push to /data/local/tmp/mini... instead of /data/local/tmp * fix requests RemoteDisconnected error * Use pure-python-adb to get serials of all android devices when initializing * If adb client can't connect to the adb server, try to use adb cli to start adb server * Use pure-python-adb package to replace adb wrapper * support --mirror * fix get toast error * hot fix for executable dir * replace $ into -, fix #152 * update document * use /data/local/tmp as default exec dir * forgot to update apk version * manually merge pr 46 * parens are necessary to catch multi exception in python3 * add screenshot(format=raw), fix init timeoutError, close #114 * Replace os.path.join with string format, so can run as normal on windows * Revert changes to install\_atx\_agent * Provide alternative execute directory to /data/local/tmp, so can install to devices like 'ZUK's Z2 * Solve ZUK's no permission to /data/local/tmp problem * fix xpath wait, fix connect simulator bug, update apk, to make watchers faster * Replace os.path.join with string format, so can run as normal on windows * Revert changes to install\_atx\_agent * Provide alternative execute directory to /data/local/tmp, so can install to devices like 'ZUK's Z2 * hot fix for session launch * fix fix * update apk version to fix #138 #137 * update view * add xpath support * fix session can not start app error * start atx-agent if atx-agent dead when connect\_usb * fix ext/htmlreport unpatch * exists return class, fix watchers.watched not working bug * add toast capture support * add d.watchers.watched = True support 0.1.2 ----- * Import update on uiautomator-server, fix current app function fix #41 * \_wait\_install\_finished 增加 hasattr(sys.stdout, 'isatty')判断 * fix current\_ime() failed * Solve ZUK's no permission to /data/local/tmp problem * add shell function in order to replace adb\_shell one day * support long running command * package info should return None * comment useless code * update apk version, try to catch NullException * run code again for NullObjectException and StaleObjectException * fix install -g error * handle StaleObjectException * fix dns when network change * only build in python 2.7 * add healthcheck in command line * update travis * format code, add click\_gone function * change prompt * add double click support * add proxyhttp.go not finished yet * stash code * add support to patch long\_click * add fancybox into htmlreport * add qqicon 0.1.1 ----- * fix message in None error * try to fix #73 * update atx-agent version * add screenshot into cli * fix for failed to init * modified for android simulator * add docstring for swipe\_points * add swipe points description * add --ignore-apk-check option * add issue template * little fix * wait disable\_popups for fix * UiObject support long\_click with duration * add issue robot * support back to init multi devices * if adb without -g, remove -g and try again * add DeleteImmediatelly in disable\_popups * update apk version to support toast * add support to show toast * add how to do with popups * update version * add disable\_popups support * update atx agent * change TMPDIR to support upload large file * fix UINotFoundEncoding error * check if apk installed after init * open u2 github URL after success init * add adbkit-init * fix raise exception unicode code encode error * fix click\_nowait missing error * support stop uiautomator keeper * fix htmlreport * add some useful link * add htmlreport support, remove click\_nowait and tap 0.1.0 ----- * add session support * add syntax error retry on screenshot error * hot fix to fix atx-agent screenshot bug * 修改import错误 :ImportError: cannot import name popup * update atx-agent version * send\_keys use adb shell input text when set\_fast\_ime failed. upgrade pos\_rel2abs function * add tkgui for experiment * show better app\_install progress on noatty, make healthcheck better * update TOC * sync to atx-agent new download logic * travis fight * no android for now * boring travis non-python pip problem * fix travis build * add Android emulator to travis and deploy only once on py2.7 * clarify adb\_shell; fix typos * Update README.md * fix healthcheck on xiaomi device 0.0.3 ----- * fix apk version name * hot fix * not raise RuntimeError in current\_app() * add window\_size api * remove ReadTimeout from jsonrpc\_retry\_call * update logic, when uiautomator2 is down, restart apk * fix input method * add timeout in screenshot and restart uiautomator.apk shen connect 502 * hot fix for weditor * stop uiautomator before start when do healthcheck() * open identify activity with am start -n * fix deprecated warn error * deprecated set\_click\_post\_delay * add deault wait\_timeout set support * add retry to prevent screenshot error on some special conditions * update screenshot to support opencv * update atx agent version * update the connect method * update atx-agent version * add push\_url api * 增加init时对代理的支持 * support install on emulator * suppress warning when uninstall error * rename examples/powerweb to webbattery * add webpower ^\_^ * fix displayHeight error on Huawei * update atx\_agent version to 0.1.1 * make pos\_rel2abs a little faster * modify http\_timeout according to wait(timeout..) 0.0.2 ----- * update doc * update doc * support oppo auto install * add app\_install\_local, handle serial contains & * swipe\_points support percent points * long click support seconds * add minitouch install support * add minitouch but not tested * add FastInputIME * add send\_keys method * guesture relative pos to real, close #12 * fix click\_exists * add gesture and pinch * add select count and fling, scroll * update ABOUT.rst addr 0.0.1 ----- * setup travis build on all\_branches * add skip cleanup * update doc again * check com.github.uiautotor.test when init * update badge link * fix datetime error * add debug * add identify method * add default timeout to requests * update to new version * change healthcheck logic, launch com.github.uiautomator and then HOME * update atx-agent version to 0.0.9 * sync with atx-agent code * when device ip is empty, connect\_usb will be called * add pull support * support stop in app\_start * add app-stop-all method * add unlock cli * add watcher support * update install guide * add pypi version badge * add readme * am\_start add stop param * click when exists * add healthcheck and connect\_usb, close #3 * add unlock method * add delay after click * fix abilist is empty error * add session check(check if app is alive when test is running * fix atx-agent install error * add clear cache support * add pushfile support * support kill all apps * support percent positions * fix detect device from adb devices -l error * remove useless print * support init multi devices * support percent tap, recode init logic * fix raise UiObjectNotFoundError error * fix incompatible in py3 * tired, want to sleep * add output * fix auto install method * add auto install requirements scripts * update document * screenshot return PIL.Image * ref |> update function app\_start(..) can input packagename and activity to start app * update doc to lastest * add selector long\_click, update some doc * add example test * set default port to 7912 * update readme * add connect(..) and add some doc * fix some error * initial project * Initial commit ================================================ FILE: DEVELOP.md ================================================ ## Local development ``` git clone https://github.com/openatx/uiautomator2 cd uiautomator2 pip install poetry poetry install # download apk to assets/ make sync # run python shell after device or emulator connected poetry run uiautomator2 console ``` ## ViewConfiguration Default configuration can retrived from [/android/view/ViewConfiguration.java](https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/view/ViewConfiguration.java) > Unit: ms - TAP_TIMEOUT: 100 - LONG_PRESS_TIMEOUT: 500 - DOUBLE_TAP_TIMEOUT: 300 ================================================ FILE: HISTORY.md ================================================ ## 项目背景 大约在2017年的时候,我在做Android自动化相关的工作,当时的脚本是用的Python写的,所以去网上找了下相关的开源项目。 刚好找到了 https://github.com/xiaocong/uiautomator 原理是在手机上运行了一个http rpc服务,将uiautomator中的功能开放出来,然后再将这些http接口封装成Python库。这个库写的实在是太好了,爱不释手。 但是这个项目很久也没更新了,也联系不上作者,于是我就fork了一个版本 为了方便做区分我们就在后面加了个2,从uiautomator变成了uiautomator2 - [openatx/uiautomator2](https://github.com/openatx/uiautomator2) - [openatx/android-uiautomator-server](https://github.com/openatx/android-uiautomator-server) 增加了各种各样的代码,对其中的bug做了修复。 期间也衍生出来的很多其他项目 - 自动化工具 https://github.com/NeteaseGame/ATX 废弃 - 设备管理平台(也支持iOS) [atxserver2](https://github.com/openatx/atxserver2) 废弃 - 纯Python的ADB客户端 https://github.com/openatx/adbutils 这个还健康的存活着 - https://github.com/openatx/weditor 不维护了,不过有开发了一个新的。 https://uiauto.dev - [uiauto.dev](https://uiauto.dev) 用于查看UI层级结构,类似于uiautomatorviewer(用于替代之前写的weditor),用于查看UI层级结构 ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2017 openatx Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ .PHONY: build format: poetry run isort . -m HANGING_INDENT -l 120 test: poetry run pytest -v mobile_tests/ covtest: poetry run coverage run -m pytest -v demo_tests tests poetry run coverage html --include 'uiautomator2/**' cov: poetry run pytest -v tests/ \ --cov-config=.coveragerc \ --cov uiautomator2 \ --cov-report xml \ --cov-report term \ --junitxml=junit.xml -o junit_family=legacy sync: cd uiautomator2/assets; ./sync.sh; cd - build: poetry self add "poetry-dynamic-versioning[plugin]" cd uiautomator2/assets; ./sync.sh; cd - rm -fr dist poetry build -vvv init: if [ ! -f "ApiDemos-debug.apk" ]; then \ wget https://github.com/appium/appium/raw/master/packages/appium/sample-code/apps/ApiDemos-debug.apk; \ fi poetry run python -m adbutils -i ./ApiDemos-debug.apk ================================================ FILE: QUICK_REFERENCE.md ================================================ # QUICK REFENRECE GUIDE ```python import uiautomator2 as u2 d = u2.connect("--serial-here--") # 只有一个设备也可以省略参数 d = u2.connect() # 一个设备时, read env-var ANDROID_SERIAL # 信息获取 print(d.info) print(d.device_info) width, height = d.window_size() print(d.wlan_ip) print(d.serial) ## 截图 d.screenshot() # Pillow.Image.Image格式 d.screenshot().save("current_screen.jpg") # 获取hierarchy d.dump_hierarchy() # str # 设置查找元素等待时间,单位秒 d.implicitly_wait(10) d.app_current() # 获取前台应用 packageName, activity d.app_start("io.appium.android.apis") # 启动应用 d.app_start("io.appium.android.apis", stop=True) # 启动应用前停止应用 d.app_stop("io.appium.android.apis") # 停止应用 app = d.session("io.appium.android.apis") # 启动应用并获取session # session的用途是操作的同时监控应用是否闪退,当闪退时操作,会抛出SessionBrokenError app.click(10, 20) # 坐标点击 # 无session状态下操作 d.click(10, 20) # 坐标点击 d.long_click(10, 10) d.double_click(10, 20) d.swipe(10, 20, 80, 90) # 从(10, 20)滑动到(80, 90) d.swipe_ext("right") # 整个屏幕右滑动 d.swipe_ext("right", scale=0.9) # 屏幕右滑,滑动距离为屏幕宽度的90% d.drag(10, 10, 80, 80) d.press("back") # 模拟点击返回键 d.press("home") # 模拟Home键 d.long_press("volume_up") d.send_keys("hello world") # 模拟输入,需要光标已经在输入框中才可以 d.clear_text() # 清空输入框 d.screen_on() # wakeUp d.screen_off() # sleep screen print(d.orientation) # left|right|natural|upsidedown d.orientation = 'natural' d.freeze_rotation(True) print(d.last_toast) # 获取显示的toast文本 d.clear_toast() # 重置一下 d.open_notification() d.open_quick_settings() d.open_url("https://www.baidu.com") d.keyevent("HOME") # same as: input keyevent HOME # 执行shell命令 output, exit_code = d.shell("ps -A", timeout=60) # 执行shell命令,获取输出和exitCode output = d.shell("pwd").output # 这样也可以 exit_code = d.shell("pwd").exit_code # 这样也可以 # Selector操作 sel = d(text="Gmail") sel.wait() sel.click() ``` ```python # XPath操作 # 元素操作 d.xpath("立即开户").wait() # 等待元素,最长等10s(默认) d.xpath("立即开户").wait(timeout=10) # 修改默认等待时间 # 常用配置 d.settings['wait_timeout'] = 20 # 控件查找默认等待时间(默认20s) d.xpath("立即开户").click() # 包含查找等待+点击操作,匹配text或者description等于立即开户的按钮 d.xpath("//*[@text='私人FM']/../android.widget.ImageView").click() d.xpath('//*[@text="私人FM"]').get().info # 获取控件信息 for el in d.xpath('//android.widget.EditText').all(): print("rect:", el.rect) # output tuple: (left_x, top_y, width, height) print("bounds:", el.bounds) # output tuple: (left, top, right, bottom) print("center:", el.center()) el.click() # click operation print(el.elem) # 输出lxml解析出来的Node # 监控弹窗(在线程中监控) d.watcher.when("跳过").click() d.watcher.start() ``` **欢迎多提意见。更欢迎Pull Request** ================================================ FILE: README.md ================================================ # uiautomator2 [![PyPI](https://img.shields.io/pypi/v/uiautomator2.svg)](https://pypi.python.org/pypi/uiautomator2) ![PyPI](https://img.shields.io/pypi/pyversions/uiautomator2.svg) [![codecov](https://codecov.io/gh/openatx/uiautomator2/graph/badge.svg?token=d0ZLkqorBu)](https://codecov.io/gh/openatx/uiautomator2) [📖 Read the Chinese version](README_CN.md) A simple, easy-to-use, and stable Android automation library. - QQ Group: 815453846 - Discord: > Users still on version 2.x.x, please check [2to3](docs/2to3.md) before deciding to upgrade to 3.x.x (Upgrade is highly recommended). ## How it Works This framework mainly consists of two parts: 1. **Device Side**: Runs an HTTP service based on UiAutomator, providing various interfaces for Android automation. 2. **Python Client**: Communicates with the device side via HTTP protocol, invoking UiAutomator's various functions. Simply put, it exposes Android automation capabilities to Python through HTTP interfaces. This design makes Python-side code writing simpler and more intuitive. # Dependencies - Android version 4.4+ - Python 3.8+ # Installation ```sh pip install uiautomator2 # Check if installation was successful, normally it will output the library version uiautomator2 version # or: python -m uiautomator2 version ``` Install element inspection tool (optional, but highly recommended): > For more detailed usage instructions, refer to: https://github.com/codeskyblue/uiautodev QQ:536481989 ```sh pip install uiautodev # After starting from the command line, it will automatically open the browser uiautodev # or: python -m uiautodev ``` Alternatives: uiautomatorviewer, Appium Inspector # Quick Start Prepare an Android phone with `Developer options` enabled, connect it to the computer, and ensure that `adb devices` shows the connected device. Open a Python interactive window. Then, input the following commands into the window. ```python import uiautomator2 as u2 d = u2.connect() # Specify device serial number if multiple devices are connected print(d.info) # Expected output # {'currentPackageName': 'net.oneplus.launcher', 'displayHeight': 1920, 'displayRotation': 0, 'displaySizeDpX': 411, 'displaySizeDpY': 731, 'displayWidth': 1080, 'productName': 'OnePlus5', 'screenOn': True, 'sdkInt': 27, 'naturalOrientation': True} ``` Example script: ```python import uiautomator2 as u2 d = u2.connect('Q5S5T19611004599') d.app_start('tv.danmaku.bili', stop=True) # Start Bilibili d.wait_activity('.MainActivityV2') d.sleep(5) # Wait for splash screen ad to disappear d.xpath('//*[@text="我的"]').click() # Click "My" # Get fan count fans_count = d.xpath('//*[@resource-id="tv.danmaku.bili:id/fans_count"]').text print(f"Fan count: {fans_count}") ``` # Documentation ## Connecting to Device Method 1: Connect using device serial number, e.g., `Q5S5T19611004599` (seen from `adb devices`) ```python import uiautomator2 as u2 d = u2.connect('Q5S5T19611004599') # alias for u2.connect_usb('123456f') print(d.info) ``` Method 2: Serial number can be passed via environment variable `ANDROID_SERIAL` ```python # export ANDROID_SERIAL=Q5S5T19611004599 d = u2.connect() ``` Method 3: Specify device via transport_id ```sh $ adb devices -l Q5S5T19611004599 device 0-1.2.2 product:ELE-AL00 model:ELE_AL00 device:HWELE transport_id:6 ``` Here you can see `transport_id:6`. > You can also get all connected transport_ids via `adbutils.adb.list(extended=True)` > Refer to https://github.com/openatx/adbutils ```python import adbutils # Requires version >=2.9.1 import uiautomator2 as u2 dev = adbutils.device(transport_id=6) d = u2.connect(dev) ``` ## Operating Elements with XPath What is XPath: XPath is a query language for locating content in XML or HTML documents. It uses simple syntax rules to establish a path from the root node to the desired element. Basic Syntax: - `/` - Select from the root node - `//` - Select from any position starting from the current node - `.` - Select the current node - `..` - Select the parent of the current node - `@` - Select attributes - `[]` - Predicate expression, used for filtering conditions You can quickly generate XPath using [UIAutoDev](https://uiauto.dev). Common Usage: ```python d.xpath('//*[@text="私人FM"]').click() # Click element with text "私人FM" # Syntactic sugar d.xpath('@personal-fm') # Equivalent to d.xpath('//*[@resource-id="personal-fm"]') sl = d.xpath("@com.example:id/home_searchedit") # sl is an XPathSelector object sl.click() sl.click(timeout=10) # Specify timeout, throws XPathElementNotFoundError if not found sl.click_exists() # Click if exists, returns whether click was successful sl.click_exists(timeout=10) # Wait up to 10s # Wait for the corresponding element to appear, returns XMLElement # Default wait time is 10s el = sl.wait() el = sl.wait(timeout=15) # Wait 15s, returns None if not found # Wait for element to disappear sl.wait_gone() sl.wait_gone(timeout=15) # Similar to wait, but throws XPathElementNotFoundError if not found el = sl.get() el = sl.get(timeout=15) sl.get_text() # Get component text sl.set_text("") # Clear input field sl.set_text("hello world") # Input "hello world" into input field ``` For more usage, refer to [XPath Interface Document](XPATH.md) ## Plugins - webview: https://github.com/YuYoungG/uiautomator2-webview To maintain the project's simplicity and extensibility, future plugins will be integrated as third-party libraries. ## Operating Elements with UiAutomator API ### Element Wait Timeout Set element search wait time (default 20s) ```python d.implicitly_wait(10.0) # Can also be modified via d.settings['wait_timeout'] = 10.0 print("wait timeout", d.implicitly_wait()) # get default implicit wait # Throws UiObjectNotFoundError if "Settings" does not appear in 10s d(text="Settings").click() ``` Wait timeout affects the following functions: `click`, `long_click`, `drag_to`, `get_text`, `set_text`, `clear_text`. ### Get Device Information Information obtained via UiAutomator: ```python d.info # Output {'currentPackageName': 'com.android.systemui', 'displayHeight': 1560, 'displayRotation': 0, 'displaySizeDpX': 360, 'displaySizeDpY': 780, 'displayWidth': 720, 'naturalOrientation': True, 'productName': 'ELE-AL00', 'screenOn': True, 'sdkInt': 29} ``` Get device information (based on `adb shell getprop` command): ```python print(d.device_info) # output {'arch': 'arm64-v8a', 'brand': 'google', 'model': 'sdk_gphone64_arm64', 'sdk': 34, 'serial': 'EMULATOR34X1X19X0', 'version': 14} ``` Get screen physical size (depends on `adb shell wm size`): ```python print(d.window_size()) # device upright output example: (1080, 1920) # device horizontal output example: (1920, 1080) ``` Get current App (depends on `adb shell`): ```python print(d.app_current()) # Output example 1: {'activity': '.Client', 'package': 'com.netease.example', 'pid': 23710} # Output example 2: {'activity': '.Client', 'package': 'com.netease.example'} # Output example 3: {'activity': None, 'package': None} ``` Wait for Activity (depends on `adb shell`): ```python d.wait_activity(".ApiDemos", timeout=10) # default timeout 10.0 seconds # Output: true or false ``` Get device serial number: ```python print(d.serial) # output example: 74aAEDR428Z9 ``` Get device WLAN IP (depends on `adb shell`): ```python print(d.wlan_ip) # output example: 10.0.0.1 or None ``` ### Clipboard Set or get clipboard content. * clipboard/set_clipboard ```python # Set clipboard d.clipboard = 'hello-world' # or d.set_clipboard('hello-world', 'label') # Get clipboard # Depends on input method (com.github.uiautomator/.AdbKeyboard) d.set_input_ime() print(d.clipboard) ``` ### Key Events * Turn on/off screen ```python d.screen_on() # turn on the screen d.screen_off() # turn off the screen ``` * Get current screen status ```python d.info.get('screenOn') ``` * Press hard/soft key ```python d.press("home") # press the home key, with key name d.press("back") # press the back key, with key name d.press(0x07, 0x02) # press keycode 0x07('0') with META ALT(0x02) ``` * These key names are currently supported: - home - back - left - right - up - down - center - menu - search - enter - delete ( or del) - recent (recent apps) - volume_up - volume_down - volume_mute - camera - power You can find all key code definitions at [Android KeyEvent](https://developer.android.com/reference/android/view/KeyEvent.html) * Unlock screen ```python d.unlock() # This is equivalent to # 1. press("power") # 2. swipe from left-bottom to right-top ``` ### Gesture interaction with the device * Click on the screen ```python d.click(x, y) ``` * Double click ```python d.double_click(x, y) d.double_click(x, y, 0.1) # default duration between two clicks is 0.1s ``` * Long click on the screen ```python d.long_click(x, y) d.long_click(x, y, 0.5) # long click 0.5s (default) ``` * Swipe ```python d.swipe(sx, sy, ex, ey) d.swipe(sx, sy, ex, ey, 0.5) # swipe for 0.5s (default) ``` * SwipeExt (Extended functionality) ```python d.swipe_ext("right") # Swipe right, 4 options: "left", "right", "up", "down" d.swipe_ext("right", scale=0.9) # Default 0.9, swipe distance is 90% of screen width d.swipe_ext("right", box=(0, 0, 100, 100)) # Swipe within the area (0,0) -> (100, 100) # In practice, starting swipe from the midpoint for up/down swipes has a higher success rate d.swipe_ext("up", scale=0.8) # Can also use Direction as a parameter from uiautomator2 import Direction d.swipe_ext(Direction.FORWARD) # Scroll down page, equivalent to d.swipe_ext("up"), but easier to understand d.swipe_ext(Direction.BACKWARD) # Scroll up page d.swipe_ext(Direction.HORIZ_FORWARD) # Scroll page horizontally right d.swipe_ext(Direction.HORIZ_BACKWARD) # Scroll page horizontally left ``` * Drag ```python d.drag(sx, sy, ex, ey) d.drag(sx, sy, ex, ey, 0.5) # drag for 0.5s (default) ``` * Swipe points ```python # swipe from point(x0, y0) to point(x1, y1) then to point(x2, y2) # time will be 0.2s between two points d.swipe_points([(x0, y0), (x1, y1), (x2, y2)], 0.2) ``` Often used for pattern unlock, get relative coordinates of each point beforehand (supports percentages). For more detailed usage, refer to this post [Using u2 to implement pattern unlock](https://testerhome.com/topics/11034) * Touch and drag (Beta) This is a lower-level raw interface, feels incomplete but usable. Note: percentages are not supported here. ```python d.touch.down(10, 10) # Simulate press down time.sleep(.01) # Delay between down and move, control it yourself d.touch.move(15, 15) # Simulate move d.touch.up(10, 10) # Simulate release ``` Note: click, swipe, drag operations support percentage position values. Example: `d.long_click(0.5, 0.5)` means long click center of screen. ### Screen Related APIs * Retrieve/Set device orientation The possible orientations: - `natural` or `n` - `left` or `l` - `right` or `r` - `upsidedown` or `u` (cannot be set) ```python # retrieve orientation. the output could be "natural" or "left" or "right" or "upsidedown" orientation = d.orientation # WARNING: did not pass testing on my TT-M1 # set orientation and freeze rotation. # notes: setting "upsidedown" requires Android>=4.3. d.set_orientation('l') # or "left" d.set_orientation("r") # or "right" d.set_orientation("n") # or "natural" ``` * Freeze/Un-freeze rotation ```python # freeze rotation d.freeze_rotation() # un-freeze rotation d.freeze_rotation(False) ``` * Take screenshot ```python # take screenshot and save to a file on the computer, requires Android>=4.2. d.screenshot("home.jpg") # get PIL.Image formatted images. Naturally, you need Pillow installed first image = d.screenshot() # default format="pillow" image.save("home.jpg") # or home.png. Currently, only png and jpg are supported # get OpenCV formatted images. Naturally, you need numpy and cv2 installed first import cv2 image = d.screenshot(format='opencv') cv2.imwrite('home.jpg', image) # get raw jpeg data imagebin = d.screenshot(format='raw') with open("some.jpg", "wb") as f: f.write(imagebin) ``` * Dump UI hierarchy ```python # get the UI hierarchy dump content xml = d.dump_hierarchy() # compressed=True: include non-important nodes (default False) # pretty: format xml (default False) # max_depth: limit xml depth, default 50 xml = d.dump_hierarchy(compressed=False, pretty=True, max_depth=30) ``` * Open notification or quick settings ```python d.open_notification() d.open_quick_settings() ``` * Show touch trace on device screen ```python # show touch trace on device screen d.show_touch_trace() # hide touch trace d.show_touch_trace(pointer_location=False, show_touches=False) ``` ### Selector Selector is a handy mechanism to identify a specific UI object in the current window. ```python # Select the object with text 'Clock' and its className is 'android.widget.TextView' d(text='Clock', className='android.widget.TextView') ``` Selector supports the below parameters. Refer to [UiSelector Java doc](http://developer.android.com/tools/help/uiautomator/UiSelector.html) for detailed information. * `text`, `textContains`, `textMatches`, `textStartsWith` * `className`, `classNameMatches` * `description`, `descriptionContains`, `descriptionMatches`, `descriptionStartsWith` * `checkable`, `checked`, `clickable`, `longClickable` * `scrollable`, `enabled`,`focusable`, `focused`, `selected` * `packageName`, `packageNameMatches` * `resourceId`, `resourceIdMatches` * `index`, `instance` #### Children and siblings * children ```python # get the children or grandchildren d(className="android.widget.ListView").child(text="Bluetooth") ``` * siblings ```python # get siblings d(text="Google").sibling(className="android.widget.ImageView") ``` * children by text or description or instance ```python # get the child matching the condition className="android.widget.LinearLayout" # and also its children or grandchildren with text "Bluetooth" d(className="android.widget.ListView", resourceId="android:id/list") \ .child_by_text("Bluetooth", className="android.widget.LinearLayout") # get children by allowing scroll search d(className="android.widget.ListView", resourceId="android:id/list") \ .child_by_text( "Bluetooth", allow_scroll_search=True, # default False className="android.widget.LinearLayout" ) ``` - `child_by_description` is to find children whose grandchildren have the specified description, other parameters being similar to `child_by_text`. - `child_by_instance` is to find children which have a child UI element anywhere within its sub-hierarchy that is at the instance specified. It is performed on visible views **without scrolling**. See below links for detailed information: - [UiScrollable](http://developer.android.com/tools/help/uiautomator/UiScrollable.html), `getChildByDescription`, `getChildByText`, `getChildByInstance` - [UiCollection](http://developer.android.com/tools/help/uiautomator/UiCollection.html), `getChildByDescription`, `getChildByText`, `getChildByInstance` Above methods support chained invoking, e.g. for the below hierarchy: ```xml ... ``` ![settings](https://raw.github.com/xiaocong/uiautomator/master/docs/img/settings.png) To click the switch widget right to the TextView 'Wi‑Fi', we need to select the switch widget first. However, according to the UI hierarchy, more than one switch widget exists and has almost the same properties. Selecting by className will not work. Alternatively, the below selecting strategy would help: ```python d(className="android.widget.ListView", resourceId="android:id/list") \ .child_by_text("Wi‑Fi", className="android.widget.LinearLayout") \ .child(className="android.widget.Switch") \ .click() ``` * relative positioning Also, we can use the relative positioning methods to get the view: `left`, `right`, `up`, `down`. - `d(A).left(B)`, selects B on the left side of A. - `d(A).right(B)`, selects B on the right side of A. - `d(A).up(B)`, selects B above A. - `d(A).down(B)`, selects B under A. So for the above cases, we can alternatively select it with: ```python ## select "switch" on the right side of "Wi‑Fi" d(text="Wi‑Fi").right(className="android.widget.Switch").click() ``` * Multiple instances Sometimes the screen may contain multiple views with the same properties, e.g. text. Then you will have to use the "instance" property in the selector to pick one of the qualifying instances, like below: ```python d(text="Add new", instance=0) # which means the first instance with text "Add new" ``` In addition, uiautomator2 provides a list-like API (similar to jQuery): ```python # get the count of views with text "Add new" on current screen print(d(text="Add new").count) # same as count property print(len(d(text="Add new"))) # get the instance via index obj = d(text="Add new")[0] obj = d(text="Add new")[1] # ... # iterator for view in d(text="Add new"): print(view.info) # ... ``` **Notes**: when using selectors in a code block that walks through the result list, you must ensure that the UI elements on the screen remain unchanged. Otherwise, an Element-Not-Found error could occur when iterating through the list. #### Get the selected UI object status and its information * Check if the specific UI object exists ```python if d(text="Settings").exists: # True if exists, else False print("Settings button exists") # alias of above property. if d.exists(text="Settings"): print("Settings button exists") # advanced usage if d(text="Settings").exists(timeout=3): # wait for Settings to appear in 3s, same as .wait(3) print("Settings button appeared within 3 seconds") ``` * Retrieve the info of the specific UI object ```python info = d(text="Settings").info print(info) ``` Below is a possible output: ```json { "contentDescription": "", "checked": false, "scrollable": false, "text": "Settings", "packageName": "com.android.launcher", "selected": false, "enabled": true, "bounds": { "top": 385, "right": 360, "bottom": 585, "left": 200 }, "className": "android.widget.TextView", "focused": false, "focusable": true, "clickable": true, "childCount": 0, "longClickable": true, "visibleBounds": { "top": 385, "right": 360, "bottom": 585, "left": 200 }, "checkable": false } ``` * Get/Set/Clear text of an editable field (e.g., EditText widgets) ```python text_content = d(className="android.widget.EditText").get_text() # get widget text d(className="android.widget.EditText").set_text("My text...") # set the text d(className="android.widget.EditText").clear_text() # clear the text ``` * Get Widget center point ```python x, y = d(text="Settings").center() # x, y = d(text="Settings").center(offset=(0, 0)) # left-top x, y ``` * Take screenshot of widget ```python im = d(text="Settings").screenshot() im.save("settings.jpg") ``` #### Perform the click action on the selected UI object * Perform click on the specific object ```python # click on the center of the specific ui object d(text="Settings").click() # wait for element to appear for at most 10 seconds and then click d(text="Settings").click(timeout=10) # click with offset(x_offset_ratio, y_offset_ratio) from top-left of the element # click_x = x_offset_ratio * width + x_left_top # click_y = y_offset_ratio * height + y_left_top d(text="Settings").click(offset=(0.5, 0.5)) # Default: center d(text="Settings").click(offset=(0, 0)) # click left-top d(text="Settings").click(offset=(1, 1)) # click right-bottom # click if exists within 10s, default timeout 0s clicked = d(text='Skip').click_exists(timeout=10.0) # returns bool # click until element is gone, return bool is_gone = d(text="Skip").click_gone(maxretry=10, interval=1.0) # maxretry default 10, interval default 1.0s ``` * Perform long click on the specific UI object ```python # long click on the center of the specific UI object d(text="Settings").long_click() # long click with duration d(text="Settings").long_click(duration=1.0) # duration in seconds, default 0.5s ``` #### Gesture actions for the specific UI object * Drag the UI object towards another point or another UI object ```python # notes : drag cannot be used for Android<4.3. # drag the UI object to a screen point (x, y), in 0.5 seconds d(text="Settings").drag_to(x, y, duration=0.5) # drag the UI object to (the center position of) another UI object, in 0.25 seconds d(text="Settings").drag_to(text="Clock", duration=0.25) ``` * Swipe from the center of the UI object to its edge Swipe supports 4 directions: - `left` - `right` - `up` (Previously 'top') - `down` (Previously 'bottom') ```python d(text="Settings").swipe("right") d(text="Settings").swipe("left", steps=10) # steps control smoothness/speed d(text="Settings").swipe("up", steps=20) # 1 step is about 5ms, so 20 steps is about 0.1s d(text="Settings").swipe("down", steps=20) ``` * Two-point gesture from one pair of points to another (for pinch/zoom) ```python # ((start_x1, start_y1), (start_x2, start_y2)) are initial touch points # ((end_x1, end_y1), (end_x2, end_y2)) are final touch points # steps is the number of move steps to take d(text="Settings").gesture((sx1, sy1), (sx2, sy2), (ex1, ey1), (ex2, ey2), steps=100) ``` * Two-point gesture on the specific UI object (pinch in/out) Supports two gestures: - `in`: from edge to center (pinch in) - `out`: from center to edge (pinch out) ```python # notes : pinch cannot be set until Android 4.3. # from edge to center. d(text="Settings").pinch_in(percent=100, steps=10) # percent of object size, steps for smoothness # from center to edge d(text="Settings").pinch_out(percent=100, steps=10) ``` * Wait until the specific UI appears or disappears ```python # wait until the ui object appears appeared = d(text="Settings").wait(timeout=3.0) # return bool if appeared: print("Settings appeared") # wait until the ui object is gone gone = d(text="Settings").wait_gone(timeout=1.0) # return bool if gone: print("Settings disappeared") ``` The default timeout is 20s. See **Global Settings** for more details. * Perform fling on the specific UI object (scrollable) Possible properties: - `horizontal` or `vertical` (or `horiz`, `vert`) - `forward` or `backward` or `toBeginning` or `toEnd` ```python # fling forward(default) vertically(default) d(scrollable=True).fling() # fling forward horizontally d(scrollable=True).fling.horizontal.forward() # fling backward vertically d(scrollable=True).fling.vertical.backward() # fling to beginning horizontally d(scrollable=True).fling.horizontal.toBeginning(max_swipes=1000) # fling to end vertically d(scrollable=True).fling.vertical.toEnd() ``` * Perform scroll on the specific UI object (scrollable) Possible properties: - `horizontal` or `vertical` (or `horiz`, `vert`) - `forward` or `backward` or `toBeginning` or `toEnd`, or `to(selector)` ```python # scroll forward(default) vertically(default) d(scrollable=True).scroll(steps=10) # scroll forward horizontally d(scrollable=True).scroll.horizontal.forward(steps=100) # scroll backward vertically d(scrollable=True).scroll.vertical.backward() # scroll to beginning horizontally d(scrollable=True).scroll.horizontal.toBeginning(steps=100, max_swipes=1000) # scroll to end vertically d(scrollable=True).scroll.vertical.toEnd() # scroll forward vertically until specific ui object appears d(scrollable=True).scroll.vertical.to(text="Security") ``` ### Input Method (IME) > IME APK: https://github.com/openatx/android-uiautomator-server/releases (Install this for reliable text input) ```python d.send_keys("Hello123abcEFG") # Send text d.send_keys("Hello123abcEFG", clear=True) # Clear existing text then send d.clear_text() # Clear all content in the input field # Automatically performs Enter, Search, etc., based on input field requirements. Added in version 3.1 d.send_action() # Can also specify the IME action, e.g., d.send_action("search"). Supports go, search, send, next, done, previous. d.hide_keyboard() # Hide the soft keyboard ``` When `send_keys` is used, it prioritizes using the clipboard for input. If the clipboard interface is unavailable, it will attempt to install and use an auxiliary IME. ```python print(d.current_ime()) # Get current IME ID (package/class) ``` > For more, refer to: [IME_ACTION_CODE](https://developer.android.com/reference/android/view/inputmethod/EditorInfo) ### Toast ```python last_toast_message = d.toast.get_message(wait_timeout=5, default=None) # Get last toast message text within 5s print(last_toast_message) d.toast.reset() # Clear last toast message cache # d.toast.show("Hello", duration=3) # Show a toast (requires special permissions) ``` ### WatchContext (Deprecated) Note: This interface is not highly recommended. It's better to check for pop-ups before clicking elements. The current `watch_context` uses threading and checks every 2 seconds. Currently, only `click` is a trigger operation. ```python with d.watch_context() as ctx: # When "Download Now" or "Update Now" and "Cancel" buttons appear simultaneously, click "Cancel" ctx.when("^(立即下载|立即更新)$").when("取消").click() ctx.when("同意").click() ctx.when("确定").click() # The above three lines execute immediately without waiting. ctx.wait_stable() # Start pop-up monitoring and wait for the interface to stabilize (stable if no pop-ups in two check cycles) # Use the call function to trigger a callback # call supports two parameters, d and el, order doesn't matter, can be omitted. If passed, variable names must be correct. # e.g., When an element matching "Midsummer Night" appears, click the back button ctx.when("仲夏之夜").call(lambda d: d.press("back")) ctx.when("确定").call(lambda el: el.click()) # Other operations # For convenience, you can also use the default pop-up monitoring logic in the code # Below is the current built-in default logic. You can join the group and @ the owner to add new logic, or submit a PR directly. # when("继续使用").click() # when("移入管控").when("取消").click() # when("^(立即下载|立即更新)$").when("取消").click() # when("同意").click() # when("^(好的|确定)$").click() with d.watch_context(builtin=True) as ctx: # Add on top of existing logic ctx.when("@tb:id/jview_view").when('//*[@content-desc="图片"]').click() # Other script logic ``` Alternative way: ```python ctx = d.watch_context() ctx.when("设置").click() ctx.wait_stable() # Wait until the interface no longer has pop-ups ctx.start() # if not using with statement # ... do something ... ctx.stop() # or ctx.close() ``` ### Global Settings ```python import uiautomator2 as u2 u2.settings['HTTP_TIMEOUT'] = 60 # Default 60s, http default request timeout ``` Other configurations are mostly centralized in `d.settings`. Configurations may be added or removed based on future needs. ```python print(d.settings) # Output example: # {'operation_delay': (0, 0), # (before_op_delay, after_op_delay) in seconds # 'operation_delay_methods': ['click', 'swipe'], # methods to apply delay # 'wait_timeout': 20.0, # default element wait timeout (native operations, xpath plugin wait time) # 'xpath_debug': False, # enable xpath debug # 'xpath_timeout': 10.0 # default xpath wait timeout # } # Configure 0.5s delay before click, 1s delay after click d.settings['operation_delay'] = (0.5, 1) # Modify methods affected by delay # double_click, long_click correspond to 'click' d.settings['operation_delay_methods'] = ['click', 'swipe', 'drag', 'press'] d.settings['wait_timeout'] = 20.0 # Default control wait time d.settings['max_depth'] = 50 # Default 50, limits dump_hierarchy returned element depth ``` When settings are deprecated due to version upgrades, a DeprecatedWarning will be shown, but no exception will be raised. ```python >>> d.settings['click_before_delay'] = 1 # [W 200514 14:55:59 settings:72] d.settings['click_before_delay'] is deprecated: Use operation_delay instead ``` UiAutomator timeout settings (hidden methods): ```python >>> d.jsonrpc.setConfigurator({"waitForIdleTimeout": 100, "waitForSelectorTimeout": 0}) # Check current configurator settings >>> print(d.jsonrpc.getConfigurator()) # {'actionAcknowledgmentTimeout': 3000, # 'keyInjectionDelay': 0, # 'scrollAcknowledgmentTimeout': 200, # 'waitForIdleTimeout': 100, # 'waitForSelectorTimeout': 0} ``` To prevent client program timeouts, `waitForIdleTimeout` and `waitForSelectorTimeout` are currently set to `0` by default by uiautomator2 itself (not by the underlying uiautomator server). Refs: [Google uiautomator Configurator](https://developer.android.com/reference/android/support/test/uiautomator/Configurator) ## Application Management This part showcases how to perform app management. ### Install Application We only support installing an APK from a URL or local path. ```python # From URL d.app_install('http://some-domain.com/some.apk') # From local path # d.app_install('/path/to/your/app.apk') # This functionality might depend on adbutils or direct adb calls. # For local path, usually you'd use adbutils: # adb = adbutils.AdbClient(host="127.0.0.1", port=5037) # device = adb.device(serial=d.serial) # device.install("/path/to/your/app.apk") # Or ensure atx-agent is installed and use its features if available. # The simplest way with uiautomator2 if atx-agent is present: # d.shell(['pm', 'install', '/path/to/app.apk']) # if apk is already on device # d.push('/local/path/app.apk', '/data/local/tmp/app.apk') # d.shell(['pm', 'install', '/data/local/tmp/app.apk']) ``` ### Start Application ```python # Default method: first parses APK's mainActivity via atx-agent, then calls am start -n $package/$activity d.app_start("com.example.hello_world") # Use monkey -p com.example.hello_world -c android.intent.category.LAUNCHER 1 to start # This method has a side effect: it automatically turns off the phone's rotation lock. d.app_start("com.example.hello_world", use_monkey=True) # start with package name # Start app by specifying main activity, equivalent to calling am start -n com.example.hello_world/.MainActivity d.app_start("com.example.hello_world", ".MainActivity") # Stop app before starting d.app_start("com.example.hello_world", stop=True) ``` ### Stop Application ```python # equivalent to `am force-stop`, thus you could lose data d.app_stop("com.example.hello_world") # equivalent to `pm clear` (clears app data) d.app_clear('com.example.hello_world') ``` ### Stop All Applications ```python # stop all d.app_stop_all() # stop all apps except for com.examples.demo d.app_stop_all(excludes=['com.examples.demo']) ``` ### Get Application Information ```python info = d.app_info("com.example.demo") print(info) # expect output # { # "mainActivity": "com.github.uiautomator.MainActivity", # "label": "ATX", # "versionName": "1.1.7", # "versionCode": 1001007, # "size": 1760809 # size in bytes # } # save app icon img = d.app_icon("com.example.demo") # Returns a PIL.Image object if img: img.save("icon.png") ``` ### List All Running Applications ```python running_apps = d.app_list_running() print(running_apps) # expect output # ["com.xxxx.xxxx", "com.github.uiautomator", "xxxx"] ``` ### Wait for Application to Run ```python pid = d.app_wait("com.example.android") # Wait for app to run, returns pid (int) or 0 if timeout if not pid: print("com.example.android is not running") else: print(f"com.example.android pid is {pid}") # Wait for app to be in the foreground pid = d.app_wait("com.example.android", front=True) if pid: print("com.example.android is in foreground") # Set custom timeout (default 20.0 seconds) pid = d.app_wait("com.example.android", timeout=10.0) ``` ### Push and Pull Files * Push a file to the device ```python # push to a folder (src can be local path or BytesIO) d.push("foo.txt", "/sdcard/") # Pushes foo.txt to /sdcard/foo.txt # push and rename d.push("foo.txt", "/sdcard/bar.txt") # push fileobj import io with io.BytesIO(b"file content") as f: d.push(f, "/sdcard/from_io.txt") # push and change file access mode (mode is int, e.g., 0o755) d.push("foo.sh", "/data/local/tmp/", mode=0o755) # Pushes to /data/local/tmp/foo.sh ``` * Pull a file from the device ```python # pull /sdcard/tmp.txt to local file tmp.txt d.pull("/sdcard/tmp.txt", "tmp.txt") # FileNotFoundError will raise if the file is not found on the device try: d.pull("/sdcard/some-file-not-exists.txt", "tmp.txt") except FileNotFoundError: print("File not found on device") # Pull file content as bytes # content_bytes = d.pull("/sdcard/tmp.txt") # This is not a standard feature, use sync.read_bytes for this # For reading content directly, use the sync object: # content = d.sync.read_bytes("/sdcard/tmp.txt") ``` ### Other Application Operations ```python # Grant all runtime permissions (requires Android 6.0+ and atx-agent) # d.app_auto_grant_permissions("io.appium.android.apis") # This might be an older or specific helper # A more common way to grant permissions is via adb shell: # d.shell(['pm', 'grant', 'io.appium.android.apis', 'android.permission.READ_CONTACTS']) # Open URL scheme d.open_url("appname://appnamehost") # same as # adb shell am start -a android.intent.action.VIEW -d "appname://appnamehost" ``` ### Session (Beta) Session represents an app lifecycle. Can be used to start app, detect app crash. * Launch and close app ```python sess = d.session("com.netease.cloudmusic") # Starts NetEase Cloud Music # ... perform operations within the session context ... # sess(text="Play").click() sess.close() # Stops NetEase Cloud Music # sess.restart() # Cold starts NetEase Cloud Music (stops then starts) ``` * Use python `with` to launch and close app ```python with d.session("com.netease.cloudmusic") as sess: # sess(text="Play").click() # App will be closed automatically when exiting the 'with' block pass ``` * Attach to the running app ```python # Launch app if not running, skip launch if already running sess = d.session("com.netease.cloudmusic", attach=True) ``` * Detect app crash ```python # When app is still running # sess(text="Music").click() # operation goes normal # If app crashes or quits # sess(text="Music").click() # raises SessionBrokenError # other function calls under session will raise SessionBrokenError too ``` ```python # check if session is ok. # Warning: function name may change in the future if sess.running(): # True or False print("Session is active") else: print("Session is not active (app might have crashed or closed)") ``` ## Other APIs ### Stop Background HTTP Service Normally, when the Python program exits, the UiAutomator service on the device also exits. However, you can also stop the service via an API call. ```python d.uiautomator.stop() # Stops the uiautomator service on the device # or d.service("uiautomator").stop() ``` ### Enable Debugging Print out the HTTP request information behind the code. ```python >>> d.debug = True # This enables logging for uiautomator2 library >>> print(d.info) # Example output showing HTTP request/response # 12:32:47.182 $ curl -X POST -d '{"jsonrpc": "2.0", "id": "...", "method": "deviceInfo"}' 'http://127.0.0.1:PORT/jsonrpc/0' # 12:32:47.225 Response >>> # {"jsonrpc":"2.0","id":"...","result":{...}} # <<< END ``` For more structured logging: ```python import logging from uiautomator2 import set_log_level set_log_level(logging.DEBUG) # or logging.INFO # Or configure manually # logger = logging.getLogger("uiautomator2") # logger.setLevel(logging.DEBUG) # # setup handler, formatter etc. ``` ## Command Line Functions `$device_ip` represents the device's IP address. To specify a device, pass `--serial`, e.g., `python -m uiautomator2 --serial bff1234 `. SubCommand can be `screenshot`, `current`, etc. > 1.0.3 Added: `python -m uiautomator2` is equivalent to `uiautomator2` - `screenshot`: Take a screenshot ```bash uiautomator2 screenshot screenshot.jpg # With specific device uiautomator2 --serial screenshot screenshot.jpg ``` - `copy-assets`: Copy assets/ to current directory Used for pyinstaller、nuitka - `current`: Get current package name and activity ```bash uiautomator2 current # Output example: # { # "package": "com.android.settings", # "activity": ".Settings", # "pid": 12345 # } ``` - `uninstall`: Uninstall app ```bash uiautomator2 uninstall # Uninstall one package uiautomator2 uninstall # Uninstall multiple packages # uiautomator2 uninstall --all # Uninstall all third-party apps (Be careful!) ``` - `stop`: Stop app ```bash uiautomator2 stop com.example.app # Stop one app # uiautomator2 stop --all # Stop all apps (Be careful!) ``` - `doctor`: Check uiautomator2 environment ```bash uiautomator2 doctor # Example output: # [I 2024-04-25 19:53:36,288 __main__:101 pid:15596] uiautomator2 is OK ``` - `install`: Install APK from URL or local path ```bash uiautomator2 install http://example.com/app.apk uiautomator2 install /path/to/local/app.apk ``` - `clear`: Clear app data ```bash uiautomator2 clear ``` - `start`: Start app ```bash uiautomator2 start uiautomator2 start / ``` - `version`: Show uiautomator2 version ```bash uiautomator2 version ``` ## Differences between Google UiAutomator 2.0 and 1.x Reference: https://www.cnblogs.com/insist8089/p/6898181.html (Chinese) - **New APIs**: UiObject2, Until, By, BySelector (in UiAutomator 2.x Java library) - **Import Style**: In 2.0, `com.android.uiautomator.core.*` is deprecated. Use `android.support.test.uiautomator.*` (now `androidx.test.uiautomator.*`). - **Build System**: Maven and/or Ant (1.x); Gradle (2.0). - **Test Package Format**: From zip/jar (1.x) to APK (2.0). - **Running Tests via ADB**: - 1.x: `adb shell uiautomator runtest UiTest.jar -c package.name.ClassName` - 2.0: `adb shell am instrument -w -r -e debug false -e class package.name.ClassName#methodName package.name.test/androidx.test.runner.AndroidJUnitRunner` - **Access to Android Services/APIs**: 1.x: No; 2.0: Yes. - **Log Output**: 1.x: `System.out.print` echoes to the execution terminal; 2.0: Output to Logcat. - **Execution**: 2.0: Test cases do not need to inherit from any parent class, method names are not restricted, uses Annotations; 1.x: Needs to inherit `UiAutomatorTestCase`, test methods must start with `test`. (Note: uiautomator2 Python library abstracts away many of these Java-level differences, but understanding the underlying UiAutomator evolution can be helpful.) ## Dependent Projects - uiautomator-jsonrpc-server: (The core server running on the Android device) - adbutils: (For ADB communication) # Contributors [contributors](../../graphs/contributors) # Other Excellent Projects - : Fusing automated UI testing with scripts for effectively fuzzing Android apps. - : A collection of excellent test automation frameworks. - [google/mobly](https://github.com/google/mobly): Google's internal test framework. - : A monkey test tool based on UiAutomator. - : A well-established image-based automation framework. - : The predecessor of this project, later taken over and optimized by NetEase Guangzhou team. Features a good IDE. (archived) (Order matters, additions welcome) # LICENSE [MIT](LICENSE) ================================================ FILE: README_CN.md ================================================ # uiautomator2 [![PyPI](https://img.shields.io/pypi/v/uiautomator2.svg)](https://pypi.python.org/pypi/uiautomator2) ![PyPI](https://img.shields.io/pypi/pyversions/uiautomator2.svg) [![codecov](https://codecov.io/gh/openatx/uiautomator2/graph/badge.svg?token=d0ZLkqorBu)](https://codecov.io/gh/openatx/uiautomator2) [📖 Read the English version](README.md) 一个简单、好用、稳定的Android自动化的库 ## 工作原理 本框架主要包含两个部分: 1. 手机端: 运行一个基于UiAutomator的HTTP服务,提供Android自动化的各种接口 2. Python客户端: 通过HTTP协议与手机端通信,调用UiAutomator的各种功能 简单来说就是把Android自动化的能力通过HTTP接口的方式暴露给Python使用。这种设计使得Python端的代码编写更加简单直观。 > 还在用2.x.x版本的用户,可以先看一下[2to3](docs/2to3.md) 再决定是否要升级3.x.x (强烈建议升级) ## 交流群 - QQ交流群: 1群:815453846 2群:943964182 - Discord: # 依赖 - Android版本 4.4+ - Python 3.8+ # 安装 ```sh pip install uiautomator2 # 检查是否安装成功,正常情况下会输出库的版本好 uiautomator2 version # or: python -m uiautomator2 version ``` 安装元素查看工具(可选,但是强烈推荐) > 更详细的使用说明参考: https://github.com/codeskyblue/uiautodev QQ:536481989 ```sh pip install uiautodev # 命令行启动后会自动打开浏览器 uiautodev # or: python -m uiautodev ``` 代替品: uiautomatorviewer, Appium Inspector # 快速入门 准备一台开启了`开发者选项`的安卓手机,连接上电脑,确保执行`adb devices`可以看到连接上的设备。 打开python交互窗口。然后将下面的命令输入到窗口中。 ```python import uiautomator2 as u2 d = u2.connect() # 连接多台设备需要指定设备序列号 print(d.info) # 期望输出 # {'currentPackageName': 'net.oneplus.launcher', 'displayHeight': 1920, 'displayRotation': 0, 'displaySizeDpX': 411, 'displaySizeDpY': 731, 'displayWidth': 1080, 'productName': 'OnePlus5', 'screenOn': True, 'sdkInt': 27, 'naturalOrientation': True} ``` 脚本例子 ```python import uiautomator2 as u2 d = u2.connect('Q5S5T19611004599') d.app_start('tv.danmaku.bili', stop=True) # 启动Bilibili d.wait_activity('.MainActivityV2') d.sleep(5) # 等待开屏广告消失 d.xpath('//*[@text="我的"]').click() # 获取粉丝数量 fans_count = d.xpath('//*[@resource-id="tv.danmaku.bili:id/fans_count"]').text print(f"粉丝数量: {fans_count}") ``` # 使用文档 ## 连接设备 方法1: 使用设备序列号链接设备 例如序列号. `Q5S5T19611004599` (seen from `adb devices`) ```python import uiautomator2 as u2 d = u2.connect('Q5S5T19611004599') # alias for u2.connect_usb('123456f') print(d.info) ``` 方法2: 序列号可以通过环境变量传递 `ANDROID_SERIAL` ```python # export ANDROID_SERIAL=Q5S5T19611004599 d = u2.connect() ``` 方法3: 通过transport_id指定设备 ```sh $ adb devices -l Q5S5T19611004599 device 0-1.2.2 product:ELE-AL00 model:ELE_AL00 device:HWELE transport_id:6 ``` 这里可以看到transport_id:6 > 也可以通过 adbutils.adb.list(extended=True)获取所有连接的transport_id > 参考 https://github.com/openatx/adbutils ```python import adbutils # 需要版本>=2.9.1 import uiautomator2 as u2 dev = adbutils.device(transport_id=6) d = u2.connect(dev) ``` ## 通过XPath操作元素 什么是XPath: XPath 是一种在 XML 或 HTML 文档中定位内容的查询语言。它使用简单的语法规则建立从根节点到所需元素的路径。 基本语法: - `/` - 从根节点开始选择 - `//` - 从当前节点开始选择任意位置 - `.` - 选择当前节点 - `..` - 选择当前节点的父节点 - `@` - 选择属性 - `[]` - 谓语表达式,用于过滤条件 通过[UIAutoDev](https://uiauto.dev)可以快速的生成XPath 常用用法 ```python d.xpath('//*[@text="私人FM"]').click() # 语法糖 d.xpath('@personal-fm') # 等价于 d.xpath('//*[@resource-id="personal-fm"]') sl = d.xpath("@com.example:id/home_searchedit") # sl为XPathSelector对象 sl.click() sl.click(timeout=10) # 指定超时时间, 找不到抛出异常 XPathElementNotFoundError sl.click_exists() # 存在即点击,返回是否点击成功 sl.click_exists(timeout=10) # 等待最多10s钟 # 等到对应的元素出现,返回XMLElement # 默认的等待时间是10s el = sl.wait() el = sl.wait(timeout=15) # 等待15s, 没有找到会返回None # 等待元素消失 sl.wait_gone() sl.wait_gone(timeout=15) # 跟wait用法类似,区别是如果没找到直接抛出 XPathElementNotFoundError 异常 el = sl.get() el = sl.get(timeout=15) sl.get_text() # 获取组件名 sl.set_text("") # 清空输入框 sl.set_text("hello world") # 输入框输入 hello world ``` 更多用法参考 [XPath接口文档](XPATH_CN.md) ## 插件 - webview: https://github.com/YuYoungG/uiautomator2-webview 为了保持项目的简洁与可扩展性,后续插件将以第三方库的形式接入。 ## 通过UiAutomator接口操作元素 ### 元素等待时长 设置元素查找等待时间(默认20s) ```python d.implicitly_wait(10.0) # 也可以通过d.settings['wait_timeout'] = 10.0 修改 print("wait timeout", d.implicitly_wait()) # get default implicit wait # 如果Settings 10s没有出现就抛出异常 UiObjectNotFoundError d(text="Settings").click() ``` 等待时长影响如下函数 `click`, `long_click`, `drag_to`, `get_text`, `set_text`, `clear_text` ### 获取设备信息 通过UiAutomator获取到的信息 ```python d.info # Output {'currentPackageName': 'com.android.systemui', 'displayHeight': 1560, 'displayRotation': 0, 'displaySizeDpX': 360, 'displaySizeDpY': 780, 'displayWidth': 720, 'naturalOrientation': True, 'productName': 'ELE-AL00', 'screenOn': True, 'sdkInt': 29} ``` 获取设备信息(基于adb shell getprop命令) ```python print(d.device_info) # output {'arch': 'arm64-v8a', 'brand': 'google', 'model': 'sdk_gphone64_arm64', 'sdk': 34, 'serial': 'EMULATOR34X1X19X0', 'version': 14} ``` 获取屏幕物理尺寸 (依赖adb shell wm size) ```python print(d.window_size()) # device upright output example: (1080, 1920) # device horizontal output example: (1920, 1080) ``` 获取当前App (依赖adb shell) ```python print(d.app_current()) # Output example 1: {'activity': '.Client', 'package': 'com.netease.example', 'pid': 23710} # Output example 2: {'activity': '.Client', 'package': 'com.netease.example'} # Output example 3: {'activity': None, 'package': None} ``` 等待Activity (依赖adb shell) ```python d.wait_activity(".ApiDemos", timeout=10) # default timeout 10.0 seconds # Output: true of false ``` 获取设备序列号 ```python print(d.serial) # output example: 74aAEDR428Z9 ``` 获取设备WLAN IP (依赖adb shell) ```python print(d.wlan_ip) # output example: 10.0.0.1 or None ``` ### 剪贴板 设置粘贴板内容或获取内容 * clipboard/set_clipboard ```python # 设置剪贴板 d.clipboard = 'hello-world' # or d.set_clipboard('hello-world', 'label') # 获取剪贴板 # 依赖输入法(com.github.uiautomator/.AdbKeyboard) d.set_input_ime() print(d.clipboard) ``` ### Key Events * Turn on/off screen ```python d.screen_on() # turn on the screen d.screen_off() # turn off the screen ``` * Get current screen status ```python d.info.get('screenOn') ``` * Press hard/soft key ```python d.press("home") # press the home key, with key name d.press("back") # press the back key, with key name d.press(0x07, 0x02) # press keycode 0x07('0') with META ALT(0x02) ``` * These key names are currently supported: - home - back - left - right - up - down - center - menu - search - enter - delete ( or del) - recent (recent apps) - volume_up - volume_down - volume_mute - camera - power You can find all key code definitions at [Android KeyEvnet](https://developer.android.com/reference/android/view/KeyEvent.html) * Unlock screen ```python d.unlock() # This is equivalent to # 1. press("power") # 2. swipe from left-bottom to right-top ``` ### Gesture interaction with the device * Click on the screen ```python d.click(x, y) ``` * Double click ```python d.double_click(x, y) d.double_click(x, y, 0.1) # default duration between two click is 0.1s ``` * Long click on the screen ```python d.long_click(x, y) d.long_click(x, y, 0.5) # long click 0.5s (default) ``` * Swipe ```python d.swipe(sx, sy, ex, ey) d.swipe(sx, sy, ex, ey, 0.5) # swipe for 0.5s(default) ``` * SwipeExt 扩展功能 ```python d.swipe_ext("right") # 手指右滑,4选1 "left", "right", "up", "down" d.swipe_ext("right", scale=0.9) # 默认0.9, 滑动距离为屏幕宽度的90% d.swipe_ext("right", box=(0, 0, 100, 100)) # 在 (0,0) -> (100, 100) 这个区域做滑动 # 实践发现上滑或下滑的时候,从中点开始滑动成功率会高一些 d.swipe_ext("up", scale=0.8) # 代码会vkk # 还可以使用Direction作为参数 from uiautomator2 import Direction d.swipe_ext(Direction.FORWARD) # 页面下翻, 等价于 d.swipe_ext("up"), 只是更好理解 d.swipe_ext(Direction.BACKWARD) # 页面上翻 d.swipe_ext(Direction.HORIZ_FORWARD) # 页面水平右翻 d.swipe_ext(Direction.HORIZ_BACKWARD) # 页面水平左翻 ``` * Drag ```python d.drag(sx, sy, ex, ey) d.drag(sx, sy, ex, ey, 0.5) # swipe for 0.5s(default) * Swipe points ```python # swipe from point(x0, y0) to point(x1, y1) then to point(x2, y2) # time will speed 0.2s bwtween two points d.swipe_points([(x0, y0), (x1, y1), (x2, y2)], 0.2)) ``` 多用于九宫格解锁,提前获取到每个点的相对坐标(这里支持百分比), 更详细的使用参考这个帖子 [使用u2实现九宫图案解锁](https://testerhome.com/topics/11034) * Touch and drap (Beta) 这个接口属于比较底层的原始接口,感觉并不完善,不过凑合能用。注:这个地方并不支持百分比 ```python d.touch.down(10, 10) # 模拟按下 time.sleep(.01) # down 和 move 之间的延迟,自己控制 d.touch.move(15, 15) # 模拟移动 d.touch.up(10, 10) # 模拟抬起 ``` Note: click, swipe, drag operations support percentage position values. Example: `d.long_click(0.5, 0.5)` means long click center of screen ### 屏幕相关接口 * Retrieve/Set device orientation The possible orientations: - `natural` or `n` - `left` or `l` - `right` or `r` - `upsidedown` or `u` (can not be set) ```python # retrieve orientation. the output could be "natural" or "left" or "right" or "upsidedown" orientation = d.orientation # WARNING: not pass testing in my TT-M1 # set orientation and freeze rotation. # notes: setting "upsidedown" requires Android>=4.3. d.set_orientation('l') # or "left" d.set_orientation("l") # or "left" d.set_orientation("r") # or "right" d.set_orientation("n") # or "natural" ``` * Freeze/Un-freeze rotation ```python # freeze rotation d.freeze_rotation() # un-freeze rotation d.freeze_rotation(False) ``` * Take screenshot ```python # take screenshot and save to a file on the computer, require Android>=4.2. d.screenshot("home.jpg") # get PIL.Image formatted images. Naturally, you need pillow installed first image = d.screenshot() # default format="pillow" image.save("home.jpg") # or home.png. Currently, only png and jpg are supported # get opencv formatted images. Naturally, you need numpy and cv2 installed first import cv2 image = d.screenshot(format='opencv') cv2.imwrite('home.jpg', image) # get raw jpeg data imagebin = d.screenshot(format='raw') open("some.jpg", "wb").write(imagebin) ``` * Dump UI hierarchy ```python # get the UI hierarchy dump content xml = d.dump_hierarchy() # compressed=True: include not import nodes # pretty: format xml # max_depth: limit xml depth, default 50 xml = d.dump_hierarchy(compressed=False, pretty=False, max_depth=50) ``` * Open notification or quick settings ```python d.open_notification() d.open_quick_settings() ``` ### Selector Selector is a handy mechanism to identify a specific UI object in the current window. ```python # Select the object with text 'Clock' and its className is 'android.widget.TextView' d(text='Clock', className='android.widget.TextView') ``` Selector supports below parameters. Refer to [UiSelector Java doc](http://developer.android.com/tools/help/uiautomator/UiSelector.html) for detailed information. * `text`, `textContains`, `textMatches`, `textStartsWith` * `className`, `classNameMatches` * `description`, `descriptionContains`, `descriptionMatches`, `descriptionStartsWith` * `checkable`, `checked`, `clickable`, `longClickable` * `scrollable`, `enabled`,`focusable`, `focused`, `selected` * `packageName`, `packageNameMatches` * `resourceId`, `resourceIdMatches` * `index`, `instance` #### Children and siblings * children ```python # get the children or grandchildren d(className="android.widget.ListView").child(text="Bluetooth") ``` * siblings ```python # get siblings d(text="Google").sibling(className="android.widget.ImageView") ``` * children by text or description or instance ```python # get the child matching the condition className="android.widget.LinearLayout" # and also its children or grandchildren with text "Bluetooth" d(className="android.widget.ListView", resourceId="android:id/list") \ .child_by_text("Bluetooth", className="android.widget.LinearLayout") # get children by allowing scroll search d(className="android.widget.ListView", resourceId="android:id/list") \ .child_by_text( "Bluetooth", allow_scroll_search=True, className="android.widget.LinearLayout" ) ``` - `child_by_description` is to find children whose grandchildren have the specified description, other parameters being similar to `child_by_text`. - `child_by_instance` is to find children with has a child UI element anywhere within its sub hierarchy that is at the instance specified. It is performed on visible views **without scrolling**. See below links for detailed information: - [UiScrollable](http://developer.android.com/tools/help/uiautomator/UiScrollable.html), `getChildByDescription`, `getChildByText`, `getChildByInstance` - [UiCollection](http://developer.android.com/tools/help/uiautomator/UiCollection.html), `getChildByDescription`, `getChildByText`, `getChildByInstance` Above methods support chained invoking, e.g. for below hierarchy ```xml ... ``` ![settings](https://raw.github.com/xiaocong/uiautomator/master/docs/img/settings.png) To click the switch widget right to the TextView 'Wi‑Fi', we need to select the switch widgets first. However, according to the UI hierarchy, more than one switch widgets exist and have almost the same properties. Selecting by className will not work. Alternatively, the below selecting strategy would help: ```python d(className="android.widget.ListView", resourceId="android:id/list") \ .child_by_text("Wi‑Fi", className="android.widget.LinearLayout") \ .child(className="android.widget.Switch") \ .click() ``` * relative positioning Also we can use the relative positioning methods to get the view: `left`, `right`, `top`, `bottom`. - `d(A).left(B)`, selects B on the left side of A. - `d(A).right(B)`, selects B on the right side of A. - `d(A).up(B)`, selects B above A. - `d(A).down(B)`, selects B under A. So for above cases, we can alternatively select it with: ```python ## select "switch" on the right side of "Wi‑Fi" d(text="Wi‑Fi").right(className="android.widget.Switch").click() ``` * Multiple instances Sometimes the screen may contain multiple views with the same properties, e.g. text, then you will have to use the "instance" property in the selector to pick one of qualifying instances, like below: ```python d(text="Add new", instance=0) # which means the first instance with text "Add new" ``` In addition, uiautomator2 provides a list-like API (similar to jQuery): ```python # get the count of views with text "Add new" on current screen d(text="Add new").count # same as count property len(d(text="Add new")) # get the instance via index d(text="Add new")[0] d(text="Add new")[1] ... # iterator for view in d(text="Add new"): view.info # ... ``` **Notes**: when using selectors in a code block that walk through the result list, you must ensure that the UI elements on the screen keep unchanged. Otherwise, when Element-Not-Found error could occur when iterating through the list. #### Get the selected ui object status and its information * Check if the specific UI object exists ```python d(text="Settings").exists # True if exists, else False d.exists(text="Settings") # alias of above property. # advanced usage d(text="Settings").exists(timeout=3) # wait Settings appear in 3s, same as .wait(3) ``` * Retrieve the info of the specific UI object ```python d(text="Settings").info ``` Below is a possible output: ``` { u'contentDescription': u'', u'checked': False, u'scrollable': False, u'text': u'Settings', u'packageName': u'com.android.launcher', u'selected': False, u'enabled': True, u'bounds': {u'top': 385, u'right': 360, u'bottom': 585, u'left': 200}, u'className': u'android.widget.TextView', u'focused': False, u'focusable': True, u'clickable': True, u'chileCount': 0, u'longClickable': True, u'visibleBounds': {u'top': 385, u'right': 360, u'bottom': 585, u'left': 200}, u'checkable': False } ``` * Get/Set/Clear text of an editable field (e.g., EditText widgets) ```python d(text="Settings").get_text() # get widget text d(text="Settings").set_text("My text...") # set the text d(text="Settings").clear_text() # clear the text ``` * Get Widget center point ```python x, y = d(text="Settings").center() # x, y = d(text="Settings").center(offset=(0, 0)) # left-top x, y ``` * Take screenshot of widget ```python im = d(text="Settings").screenshot() im.save("settings.jpg") ``` #### Perform the click action on the selected UI object * Perform click on the specific object ```python # click on the center of the specific ui object d(text="Settings").click() # wait element to appear for at most 10 seconds and then click d(text="Settings").click(timeout=10) # click with offset(x_offset, y_offset) # click_x = x_offset * width + x_left_top # click_y = y_offset * height + y_left_top d(text="Settings").click(offset=(0.5, 0.5)) # Default center d(text="Settings").click(offset=(0, 0)) # click left-top d(text="Settings").click(offset=(1, 1)) # click right-bottom # click when exists in 10s, default timeout 0s clicked = d(text='Skip').click_exists(timeout=10.0) # click until element gone, return bool is_gone = d(text="Skip").click_gone(maxretry=10, interval=1.0) # maxretry default 10, interval default 1.0 ``` * Perform long click on the specific UI object ```python # long click on the center of the specific UI object d(text="Settings").long_click() ``` #### Gesture actions for the specific UI object * Drag the UI object towards another point or another UI object ```python # notes : drag can not be used for Android<4.3. # drag the UI object to a screen point (x, y), in 0.5 second d(text="Settings").drag_to(x, y, duration=0.5) # drag the UI object to (the center position of) another UI object, in 0.25 second d(text="Settings").drag_to(text="Clock", duration=0.25) ``` * Swipe from the center of the UI object to its edge Swipe supports 4 directions: - left - right - top - bottom ```python d(text="Settings").swipe("right") d(text="Settings").swipe("left", steps=10) d(text="Settings").swipe("up", steps=20) # 1 steps is about 5ms, so 20 steps is about 0.1s d(text="Settings").swipe("down", steps=20) ``` * Two-point gesture from one point to another ```python d(text="Settings").gesture((sx1, sy1), (sx2, sy2), (ex1, ey1), (ex2, ey2)) ``` * Two-point gesture on the specific UI object Supports two gestures: - `In`, from edge to center - `Out`, from center to edge ```python # notes : pinch can not be set until Android 4.3. # from edge to center. here is "In" not "in" d(text="Settings").pinch_in(percent=100, steps=10) # from center to edge d(text="Settings").pinch_out() ``` * Wait until the specific UI appears or disappears ```python # wait until the ui object appears d(text="Settings").wait(timeout=3.0) # return bool # wait until the ui object gone d(text="Settings").wait_gone(timeout=1.0) ``` The default timeout is 20s. see **global settings** for more details * Perform fling on the specific ui object(scrollable) Possible properties: - `horiz` or `vert` - `forward` or `backward` or `toBeginning` or `toEnd` ```python # fling forward(default) vertically(default) d(scrollable=True).fling() # fling forward horizontally d(scrollable=True).fling.horiz.forward() # fling backward vertically d(scrollable=True).fling.vert.backward() # fling to beginning horizontally d(scrollable=True).fling.horiz.toBeginning(max_swipes=1000) # fling to end vertically d(scrollable=True).fling.toEnd() ``` * Perform scroll on the specific ui object(scrollable) Possible properties: - `horiz` or `vert` - `forward` or `backward` or `toBeginning` or `toEnd`, or `to` ```python # scroll forward(default) vertically(default) d(scrollable=True).scroll(steps=10) # scroll forward horizontally d(scrollable=True).scroll.horiz.forward(steps=100) # scroll backward vertically d(scrollable=True).scroll.vert.backward() # scroll to beginning horizontally d(scrollable=True).scroll.horiz.toBeginning(steps=100, max_swipes=1000) # scroll to end vertically d(scrollable=True).scroll.toEnd() # scroll forward vertically until specific ui object appears d(scrollable=True).scroll.to(text="Security") ``` ### 输入法 > 输入法APK: https://github.com/openatx/android-uiautomator-server/releases ```python d.send_keys("你好123abcEFG") d.send_keys("你好123abcEFG", clear=True) d.clear_text() # 清除输入框所有内容 d.send_action() # 根据输入框的需求,自动执行回车、搜索等指令, Added in version 3.1 # 也可以指定发送的输入法action, eg: d.send_action("search") 支持 go, search, send, next, done, previous d.hide_keyboard() # 隐藏输入法 ``` 输入法send_keys的时候,优先使用剪贴板进行输入。如果剪贴板接口无法使用,会安装辅助输入法进行输入。 ```python print(d.current_ime()) # 获取当前输入法ID ``` > 更多参考: [IME_ACTION_CODE](https://developer.android.com/reference/android/view/inputmethod/EditorInfo) ### Toast ```python print(d.last_toast) # get last toast, if not toast return None d.clear_toast() ``` ### WatchContext (废弃) 注: 这里不是很推荐用这个接口,最好点击元素前检查一下是否有弹窗 目前的这个watch_context是用threading启动的,每2s检查一次 目前还只有click这一种触发操作 ```python with d.watch_context() as ctx: # 当同时出现 (立即下载 或 立即更新)和 取消 按钮的时候,点击取消 ctx.when("^立即(下载|更新)").when("取消").click() ctx.when("同意").click() ctx.when("确定").click() # 上面三行代码是立即执行完的,不会有什么等待 ctx.wait_stable() # 开启弹窗监控,并等待界面稳定(两个弹窗检查周期内没有弹窗代表稳定) # 使用call函数来触发函数回调 # call 支持两个参数,d和el,不区分参数位置,可以不传参,如果传参变量名不能写错 # eg: 当有元素匹配仲夏之夜,点击返回按钮 ctx.when("仲夏之夜").call(lambda d: d.press("back")) ctx.when("确定").call(lambda el: el.click()) # 其他操作 # 为了方便也可以使用代码中默认的弹窗监控逻辑 # 下面是目前内置的默认逻辑,可以加群at群主,增加新的逻辑,或者直接提pr # when("继续使用").click() # when("移入管控").when("取消").click() # when("^立即(下载|更新)").when("取消").click() # when("同意").click() # when("^(好的|确定)").click() with d.watch_context(builtin=True) as ctx: # 在已有的基础上增加 ctx.when("@tb:id/jview_view").when('//*[@content-desc="图片"]').click() # 其他脚本逻辑 ``` 另外一种写法 ```python ctx = d.watch_context() ctx.when("设置").click() ctx.wait_stable() # 等待界面不在有弹窗了 ctx.close() ``` ### 全局设置 ```python u2.HTTP_TIMEOUT = 60 # 默认值60s, http默认请求超时时间 ``` 其他的配置,目前已大部分集中到 `d.settings` 中,根据后期的需求配置可能会有增减。 ```python print(d.settings) {'operation_delay': (0, 0), 'operation_delay_methods': ['click', 'swipe'], 'wait_timeout': 20.0} # 配置点击前延时0.5s,点击后延时1s d.settings['operation_delay'] = (.5, 1) # 修改延迟生效的方法 # 其中 double_click, long_click 都对应click d.settings['operation_delay_methods'] = ['click', 'swipe', 'drag', 'press'] d.settings['wait_timeout'] = 20.0 # 默认控件等待时间(原生操作,xpath插件的等待时间) d.settings['max_depth'] = 50 # 默认50,限制dump_hierarchy返回的元素层级 ``` 对于随着版本升级,设置过期的配置时,会提示Deprecated,但是不会抛异常。 ```bash >>> d.settings['click_before_delay'] = 1 [W 200514 14:55:59 settings:72] d.settings[click_before_delay] deprecated: Use operation_delay instead ``` UiAutomator中的超时设置(隐藏方法) ```python >> d.jsonrpc.getConfigurator() {'actionAcknowledgmentTimeout': 500, 'keyInjectionDelay': 0, 'scrollAcknowledgmentTimeout': 200, 'waitForIdleTimeout': 0, 'waitForSelectorTimeout': 0} >> d.jsonrpc.setConfigurator({"waitForIdleTimeout": 100}) {'actionAcknowledgmentTimeout': 500, 'keyInjectionDelay': 0, 'scrollAcknowledgmentTimeout': 200, 'waitForIdleTimeout': 100, 'waitForSelectorTimeout': 0} ``` 为了防止客户端程序响应超时,`waitForIdleTimeout`和`waitForSelectorTimeout`目前已改为`0` Refs: [Google uiautomator Configurator](https://developer.android.com/reference/android/support/test/uiautomator/Configurator) ## 应用管理 This part showcases how to perform app management ### 安装应用 We only support installing an APK from a URL ```python d.app_install('http://some-domain.com/some.apk') ``` ### 启动应用 ```python # 默认的这种方法是先通过atx-agent解析apk包的mainActivity,然后调用am start -n $package/$activity启动 d.app_start("com.example.hello_world") # 使用 monkey -p com.example.hello_world -c android.intent.category.LAUNCHER 1 启动 # 这种方法有个副作用,它自动会将手机的旋转锁定给关掉 d.app_start("com.example.hello_world", use_monkey=True) # start with package name # 通过指定main activity的方式启动应用,等价于调用am start -n com.example.hello_world/.MainActivity d.app_start("com.example.hello_world", ".MainActivity") ``` ### 停止应用 ```python # equivalent to `am force-stop`, thus you could lose data d.app_stop("com.example.hello_world") # equivalent to `pm clear` d.app_clear('com.example.hello_world') ``` ### 停止所有应用 ```python # stop all d.app_stop_all() # stop all app except for com.examples.demo d.app_stop_all(excludes=['com.examples.demo']) ``` ### 获取应用信息 ```python d.app_info("com.examples.demo") # expect output #{ # "mainActivity": "com.github.uiautomator.MainActivity", # "label": "ATX", # "versionName": "1.1.7", # "versionCode": 1001007, # "size":1760809 #} # save app icon img = d.app_icon("com.examples.demo") img.save("icon.png") ``` ### 列出所有运行的应用 ```python d.app_list_running() # expect output # ["com.xxxx.xxxx", "com.github.uiautomator", "xxxx"] ``` ### 等待应用运行 ```python pid = d.app_wait("com.example.android") # 等待应用运行, return pid(int) if not pid: print("com.example.android is not running") else: print("com.example.android pid is %d" % pid) d.app_wait("com.example.android", front=True) # 等待应用前台运行 d.app_wait("com.example.android", timeout=20.0) # 最长等待时间20s(默认) ``` ### 拉取和推送文件 * push a file to the device ```python # push to a folder d.push("foo.txt", "/sdcard/") # push and rename d.push("foo.txt", "/sdcard/bar.txt") # push fileobj with open("foo.txt", 'rb') as f: d.push(f, "/sdcard/") # push and change file access mode d.push("foo.sh", "/data/local/tmp/", mode=0o755) ``` * pull a file from the device ```python d.pull("/sdcard/tmp.txt", "tmp.txt") # FileNotFoundError will raise if the file is not found on the device d.pull("/sdcard/some-file-not-exists.txt", "tmp.txt") ``` ### 其他应用操作 ```python # grant all the permissions d.app_auto_grant_permissions("io.appium.android.apis") # open scheme d.open_url("appname://appnamehost") # same as # adb shell am start -a android.intent.action.VIEW -d "appname://appnamehost" ``` ### Session (Beta) Session represent an app lifecycle. Can be used to start app, detect app crash. * Launch and close app ```python sess = d.session("com.netease.cloudmusic") # start 网易云音乐 sess.close() # 停止网易云音乐 sess.restart() # 冷启动网易云音乐 ``` * Use python `with` to launch and close app ```python with d.session("com.netease.cloudmusic") as sess: sess(text="Play").click() ``` * Attach to the running app ```python # launch app if not running, skip launch if already running sess = d.session("com.netease.cloudmusic", attach=True) ``` * Detect app crash ```python # When app is still running sess(text="Music").click() # operation goes normal # If app crash or quit sess(text="Music").click() # raise SessionBrokenError # other function calls under session will raise SessionBrokenError too ``` ```python # check if session is ok. # Warning: function name may change in the future sess.running() # True or False ``` ## 其他接口 ### 停止后台HTTP服务 通常情况下Python程序退出了,UiAutomation就退出了。 不过也可以通过接口的方法停止服务 ```python d.stop_uiautomator() ``` ### 开启调试 打印出代码背后的HTTP请求信息 ```python >>> d.debug = True >>> d.info 12:32:47.182 $ curl -X POST -d '{"jsonrpc": "2.0", "id": "b80d3a488580be1f3e9cb3e926175310", "method": "deviceInfo", "params": {}}' 'http://127.0.0.1:54179/jsonrpc/0' 12:32:47.225 Response >>> {"jsonrpc":"2.0","id":"b80d3a488580be1f3e9cb3e926175310","result":{"currentPackageName":"com.android.mms","displayHeight":1920,"displayRotation":0,"displaySizeDpX":360,"displaySizeDpY":640,"displayWidth":1080,"productName" :"odin","screenOn":true,"sdkInt":25,"naturalOrientation":true}} <<< END ``` ```python from uiautomator2 import enable_pretty_logging enable_pretty_logging() ``` Or ``` logger = logging.getLogger("uiautomator2") # setup logger ``` ## 命令行功能 其中的`$device_ip`代表设备的ip地址 如需指定设备需要传入`--serial` 如 `python3 -m uiautomator2 --serial bff1234 `, SubCommand为子命令(screenshot, current 等) > 1.0.3 Added: `python3 -m uiautomator2` equals to `uiautomator2` - screenshot: 截图 ```bash $ uiautomator2 screenshot screenshot.jpg ``` - current: 获取当前包名和activity ```bash $ uiautomator2 current { "package": "com.android.browser", "activity": "com.uc.browser.InnerUCMobile", "pid": 28478 } ``` - uninstall: Uninstall app ```bash $ uiautomator2 uninstall # 卸载一个包 $ uiautomator2 uninstall # 卸载多个包 $ uiautomator2 uninstall --all # 全部卸载 ``` - stop: Stop app ```bash $ uiautomator2 stop com.example.app # 停止一个app $ uiautomator2 stop --all # 停止所有的app ``` - doctor: ```bash $ uiautomator2 doctor [I 2024-04-25 19:53:36,288 __main__:101 pid:15596] uiautomator2 is OK ``` ## Google UiAutomator 2.0和1.x的区别 https://www.cnblogs.com/insist8089/p/6898181.html - 新增接口:UiObject2、Until、By、BySelector - 引入方式:2.0中,com.android.uiautomator.core.* 引入方式被废弃。改为android.support.test.uiautomator - 构建系统:Maven 和/或 Ant(1.x);Gradle(2.0) - 产生的测试包的形式:从zip /jar(1.x) 到 apk(2.0) - 在本地环境以adb命令运行UIAutomator测试,启动方式的差别: adb shell uiautomator runtest UiTest.jar -c package.name.ClassName(1.x) adb shell am instrument -e class com.example.app.MyTest com.example.app.test/android.support.test.runner.AndroidJUnitRunner(2.0) - 能否使用Android服务及接口? 1.x~不能;2.0~能。 - og输出? 使用System.out.print输出流回显至执行端(1.x); 输出至Logcat(2.0) - 执行?测试用例无需继承于任何父类,方法名不限,使用注解 Annotation进行(2.0); 需要继承UiAutomatorTestCase,测试方法需要以test开头(1.x) ## 依赖项目 - [![PyPI](https://img.shields.io/pypi/v/adbutils.svg?label=adbutils)](https://github.com/openatx/adbutils) - [![GitHub tag (latest SemVer)](https://img.shields.io/github/tag/openatx/android-uiautomator-server.svg?label=android-uiautomator-server)](https://github.com/openatx/android-uiautomator-server) 已迁移到私有仓库,需要合作开发进QQ群联系群主 # Contributors [contributors](../../graphs/contributors) # 其他优秀的项目 - https://github.com/ecnusse/Kea2: 面向安卓的自动化界面遍历与脚本协同测试框架 - https://github.com/atinfo/awesome-test-automation 所有优秀测试框架的集合,包罗万象 - [google/mobly](https://github.com/google/mobly) 谷歌内部的测试框架,虽然我不太懂,但是感觉很好用 - https://github.com/zhangzhao4444/Maxim 基于Uiautomator的monkey - http://www.sikulix.com/ 基于图像识别的自动化测试框架,非常的老牌 - http://airtest.netease.com/ 本项目的前身,后来被网易广州团队接手并继续优化。实现有一个不错的IDE (archived) 排名有先后,欢迎补充 # LICENSE [MIT](LICENSE) ================================================ FILE: XPATH.md ================================================ # uiautomator2 XPath Extension [📖 阅读中文版](XPATH_CN.md) Before using this plugin, you need to understand some XPath knowledge. Fortunately, there are many convenient resources available online. Below are some examples: - [W3CSchool XPath Tutorial](http://www.w3school.com.cn/xpath/index.asp) - [XPath Tutorial](http://www.zvon.org/xxl/XPathTutorial/) - [Ruan Yifeng’s XPath Learning Notes](http://www.ruanyifeng.com/blog/2009/07/xpath_path_expressions.html) - [Website for Testing XPath](https://www.freeformatter.com/xpath-tester.html) - [XPath Tester](https://extendsclass.com/xpath-tester.html) The code has not been fully tested and may still have bugs. Feedback is welcome. ## How It Works 1. Use the `dump_hierarchy` interface from the `uiautomator2` library to obtain the current UI screen (a comprehensive XML). 2. Then use the `lxml` library to parse and search for matching XPath expressions, and perform click operations using the `click` command. > Currently, `lxml` only supports XPath 1.0. If anyone knows how to support XPath 2.0, please let me know. **Popup Monitoring Principle** The hierarchy provides information about all elements on the screen (including popups and buttons to be clicked). Suppose there are two popup buttons: `Skip` and `Got It`. The button to be clicked is `Play`. 1. Obtain the current screen’s XML (using the `dump_hierarchy` function). 2. Check if the `Skip` or `Got It` buttons are present. If they are, click them and return to step 1. 3. Check if the `Play` button is present. If it is, click it and finish. If not found, return to step 1 and keep executing until the search attempts exceed the limit. ## Installation ```bash pip3 install -U uiautomator2 ``` ## Usage ### Simple Usage Check out the following simple example to understand how to use it: ```python import uiautomator2 as u2 def main(): d = u2.connect() d.app_start("com.netease.cloudmusic", stop=True) d.xpath('//*[@text="Private FM"]').click() # # Advanced Usage (Element Positioning) # # Starting with @ d.xpath('@personal-fm') # Equivalent to d.xpath('//*[@resource-id="personal-fm"]') # Multiple condition positioning, similar to AND d.xpath('//android.widget.Button').xpath('//*[@text="Private FM"]') d.xpath('//*[@text="Private FM"]').parent() # Position to the parent element d.xpath('//*[@text="Private FM"]').parent("@android:list") # Position to the parent element that meets the condition # When using child, it is not recommended to use multiple condition XPath because it can be confusing d.xpath('@android:id/list').child('/android.widget.TextView').click() # Equivalent to the following # d.xpath('//*[@resource-id="android:id/list"]/android.widget.TextView').click() ``` > For convenience, the following code does not include `import` and `main`. It is assumed that the variable `d` exists. ### Operations of `XPathSelector` ```python sl = d.xpath("@com.example:id/home_searchedit") # sl is an XPathSelector object # Click sl.click() sl.click(timeout=10) # Specify a timeout, throws XPathElementNotFoundError if not found sl.click_exists() # Click if exists, returns whether the click was successful sl.click_exists(timeout=10) # Wait up to 10 seconds sl.match() # Returns None if not matched, otherwise returns an XMLElement # Wait for the corresponding element to appear, returns XMLElement # The default waiting time is 10 seconds el = sl.wait() el = sl.wait(timeout=15) # Wait for 15 seconds, returns None if not found # Wait for the element to disappear sl.wait_gone() sl.wait_gone(timeout=15) # Similar to wait, but throws XPathElementNotFoundError if not found el = sl.get() el = sl.get(timeout=15) # Change the default waiting time to 15 seconds d.xpath.global_set("timeout", 15) d.xpath.implicitly_wait(15) # Equivalent to the previous line (TODO: Removed) print(sl.exists) # Returns whether it exists (bool) sl.get_last_match() # Get the last matched XMLElement sl.get_text() # Get the component name sl.set_text("") # Clear the input box sl.set_text("hello world") # Input "hello world" into the input box # Iterate through all matched elements for el in d.xpath('//android.widget.EditText').all(): print("rect:", el.rect) # Output tuple: (x, y, width, height) print("center:", el.center()) el.click() # Click operation print(el.elem) # Output the Node parsed by lxml print(el.text) # Child operation d.xpath('@android:id/list').child('/android.widget.TextView').click() # Equivalent to d.xpath('//*[@resource-id="android:id/list"]/android.widget.TextView').all() ``` ### Advanced Search Syntax > Added in version 3.1 ```python # Find text=NFC AND id=android:id/item (d.xpath("NFC") & d.xpath("@android:id/item")).get() # Find text=NFC OR id=android:id/item (d.xpath("NFC") | d.xpath("App") | d.xpath("Content")).get() # Supports more complex queries ((d.xpath("NFC") | d.xpath("@android:id/item")) & d.xpath("//android.widget.TextView")).get() ``` ### Operations of `XMLElement` ```python # The object returned by XPathSelector.get() is called XMLElement el = d.xpath("@com.example:id/home_searchedit").get() lx, ly, width, height = el.rect # Get the top-left coordinates and size lx, ly, rx, ry = el.bounds # Top-left and bottom-right coordinates x, y = el.center() # Get the element’s center position x, y = el.offset(0.5, 0.5) # Same as center() # Send click el.click() # Print text content print(el.text) # Get the attributes within the group, as a dict print(el.attrib) # Take a screenshot of the control (the principle is to take a full screenshot first, then crop) el.screenshot() # Swipe the control el.swipe("right") # left, right, up, down el.swipe("right", scale=0.9) # scale defaults to 0.9, meaning the swipe distance is 90% of the control's width. Swiping up uses 90% of the height. print(el.info) # Output example { 'index': '0', 'text': '', 'resourceId': 'com.example:id/home_searchedit', 'checkable': 'true', 'checked': 'true', 'clickable': 'true', 'enabled': 'true', 'focusable': 'false', 'focused': 'false', 'scrollable': 'false', 'longClickable': 'false', 'password': 'false', 'selected': 'false', 'visibleToUser': 'true', 'childCount': 0, 'className': 'android.widget.Switch', 'bounds': {'left': 882, 'top': 279, 'right': 1026, 'bottom': 423}, 'packageName': 'com.android.settings', 'contentDescription': '', 'resourceName': 'android:id/switch_widget' } ``` ### Swipe to a Specified Position > The `scroll_to` feature is newly added and may not be fully polished (for example, it cannot detect if it has scrolled to the bottom). First, see the example: ```python from uiautomator2 import connect_usb, Direction d = connect_usb() d.scroll_to("Place Order") d.scroll_to("Place Order", Direction.FORWARD) # Defaults to scrolling down. Other options include BACKWARD, HORIZ_FORWARD (horizontal), HORIZ_BACKWARD (horizontal reverse) d.scroll_to("Place Order", Direction.HORIZ_FORWARD, max_swipes=5) # Additionally, you can scroll within a specified element d.xpath('@com.taobao.taobao:id/dx_root').scroll(Direction.HORIZ_FORWARD) d.xpath('@com.taobao.taobao:id/dx_root').scroll_to("Place Order", Direction.HORIZ_FORWARD) ``` **A More Complete Example** ```python import uiautomator2 as u2 from uiautomator2 import Direction def main(): d = u2.connect() d.app_start("com.netease.cloudmusic", stop=True) # Steps d.xpath("//*[@text='Private FM']/../android.widget.ImageView").click() d.xpath("Next Song").click() # Monitor popups for 2 seconds, the time may exceed 2 seconds d.xpath.sleep_watch(2) d.xpath("Go to Previous Level").click() d.xpath("Go to Previous Level").click(watch=False) # Click without triggering watch d.xpath("Go to Previous Level").click(timeout=5.0) # Wait timeout 5 seconds d.xpath.watch_background() # Enable background monitoring mode, checks every 4 seconds by default d.xpath.watch_background(interval=2.0) # Check every 2 seconds d.xpath.watch_stop() # Stop monitoring for el in d.xpath('//android.widget.EditText').all(): print("rect:", el.rect) # Output tuple: (left_x, top_y, width, height) print("bounds:", el.bounds) # Output tuple: (left, top, right, bottom) print("center:", el.center()) el.click() # Click operation print(el.elem) # Output the Node parsed by lxml # Swiping el = d.xpath('@com.taobao.taobao:id/fl_banner_container').get() # Swipe from right to left el.swipe(Direction.HORIZ_FORWARD) el.swipe(Direction.LEFT) # Swipe from right to left # Swipe from bottom to top el.swipe(Direction.FORWARD) el.swipe(Direction.UP) el.swipe("right", scale=0.9) # scale defaults to 0.9, swipe distance is 80% of the control's width, the swipe center aligns with the control's center el.swipe("up", scale=0.5) # Swipe distance is 50% of the control's height # scroll is different from swipe; scroll returns a bool indicating whether new elements appeared el.scroll(Direction.FORWARD) # Swipe down el.scroll(Direction.BACKWARD) # Swipe up el.scroll(Direction.HORIZ_FORWARD) # Swipe horizontally forward el.scroll(Direction.HORIZ_BACKWARD) # Swipe horizontally backward if el.scroll("forward"): print("Can continue scrolling") ``` ### `PageSource` Object > Added in version 3.1 This is an advanced usage, but this object is also the most fundamental, as almost all functions depend on it. **What is PageSource?** PageSource is initialized from the return value of `d.dump_hierarchy()`. It is mainly used to find elements through XPath. **Usage:** ```python source = d.xpath.get_page_source() # find_elements is the core method elements = source.find_elements('//android.widget.TextView') # List[XMLElement] for el in elements: print(el.text) # Get coordinates and click x, y = elements[0].center() d.click(x, y) # Multiple condition query syntax es1 = source.find_elements('//android.widget.TextView') es2 = source.find_elements(XPath('@android:id/content').joinpath("//*")) # Find TextViews that do not belong to nodes under id=android:id/content els = set(es1) - set(es2) # Find TextViews that belong to nodes under id=android:id/content els = set(es1) & set(es2) ``` ## XPath Rules To write scripts faster, we have customized some simplified XPath rules. **Rule 1** Starting with `//` represents native XPath. **Rule 2** Starting with `@` represents resourceId positioning. `@smartisanos:id/right_container` is equivalent to `//*[@resource-id="smartisanos:id/right_container"]` **Rule 3** Starting with `^` represents a regular expression. `^.*done` is equivalent to `//*[re:match(text(), '^.*done')]` **Rule 4** > Inspired by SQL LIKE `Know%` matches text starting with `Know`, equivalent to `//*[starts-with(text(), 'Know')]` `%Know` matches text ending with `Know`, equivalent to `//*[ends-with(text(), 'Know')]` `%Know%` matches text containing `Know`, equivalent to `//*[contains(text(), 'Know')]` **Last Rule** Matches both `text` and `description` fields. For example, `Search` is equivalent to XPath `//*[@text="Search" or @content-desc="Search" or @resource-id="Search"]` ## Special Notes - Sometimes, `className` contains characters like `$@#&`, which are invalid in XML. Therefore, they are all replaced with `.`. ## Some Advanced Uses of XPath ``` # All elements //* # Elements where resource-id contains 'login' //*[contains(@resource-id, 'login')] # Buttons containing 'Account' or 'Account Number' /android.widget.Button[contains(@text, 'Account') or contains(@text, 'Account Number')] # The second element among all ImageViews (//android.widget.ImageView)[2] # The last element among all ImageViews (//android.widget.ImageView)[last()] # Elements where className contains 'ImageView' //*[contains(name(), "ImageView")] ``` ## Some Useful Websites - [XPath Playground](https://scrapinghub.github.io/xpath-playground/) - [Some Advanced Uses of XPath - JianShu](https://www.jianshu.com/p/4fef4142b33f) - [XPath Quicksheet](https://devhints.io/xpath) If you have other resources, feel free to submit [Issues](https://github.com/openatx/uiautomator2/issues/new) to contribute. ================================================ FILE: XPATH_CN.md ================================================ # uiautomator2 xpath extension [📖 Read the English version](XPATH.md) 用这个插件前,要先了解一些XPath知识。 好在网上这方便的资料很多。下面列举一些 - [W3CSchool XPath教程](http://www.w3school.com.cn/xpath/index.asp) - [XPath tutorial](http://www.zvon.org/xxl/XPathTutorial/) - [阮一峰的XPath学习笔记](http://www.ruanyifeng.com/blog/2009/07/xpath_path_expressions.html) - [测试XPath的网站](https://www.freeformatter.com/xpath-tester.html) - [XPath tester](https://extendsclass.com/xpath-tester.html) 代码并没有完全测试完,可能还有bug,欢迎跟我反馈。 ## 工作原理 1. 通过uiautomator2库的`dump_hierarchy`接口,获取到当前的UI界面(一个很丰富的XML)。 2. 然后使用`lxml`库解析,寻找匹配的xpath,然后使用click指令完后操作 >目前发现lxml只支持XPath1.0, 有了解的可以告诉我下怎么支持XPath2.0 **弹窗监控原理** 通过hierarchy可以知道界面上的所有元素信息(包括弹窗和要点击的按钮)。 假设有 `跳过`, `知道了` 这两个弹窗按钮。需要点击的按钮名是 `播放` 1. 获取到当前界面的XML(通过dump_hierarchy函数) 2. 检查有没有`跳过`, `知道了` 这两个按钮,如果有就点击,然后回到第一步 3. 检查有没有`播放`按钮, 有就点击,结束。没有找到在回到第一步,一直执行到查找次数超标。 ## 安装方法 ``` pip3 install -U uiautomator2 ``` ## 使用方法 ### 简单用法 看下面的这个简单的例子了解下如何使用 ```python import uiautomator2 as u2 def main(): d = u2.connect() d.app_start("com.netease.cloudmusic", stop=True) d.xpath('//*[@text="私人FM"]').click() # # 高级用法(元素定位) # # @开头 d.xpath('@personal-fm') # 等价于 d.xpath('//*[@resource-id="personal-fm"]') # 多个条件定位, 类似于AND d.xpath('//android.widget.Button').xpath('//*[@text="私人FM"]') d.xpath('//*[@text="私人FM"]').parent() # 定位到父元素 d.xpath('//*[@text="私人FM"]').parent("@android:list") # 定位到符合条件的父元素 # 包含child的时候,不建议在使用多条件的xpath,因为容易搞混 d.xpath('@android:id/list').child('/android.widget.TextView').click() # 等价于下面这个 # d.xpath('//*[@resource-id="android:id/list"]/android.widget.TextView').click() ``` >下面的代码为了方便就不写`import`和`main`了,默认存在`d`这个变量 ### `XPathSelector`的操作 ```python sl = d.xpath("@com.example:id/home_searchedit") # sl为XPathSelector对象 # 点击 sl.click() sl.click(timeout=10) # 指定超时时间, 找不到抛出异常 XPathElementNotFoundError sl.click_exists() # 存在即点击,返回是否点击成功 sl.click_exists(timeout=10) # 等待最多10s钟 sl.match() # 不匹配返回None, 否则返回XMLElement # 等到对应的元素出现,返回XMLElement # 默认的等待时间是10s el = sl.wait() el = sl.wait(timeout=15) # 等待15s, 没有找到会返回None # 等待元素消失 sl.wait_gone() sl.wait_gone(timeout=15) # 跟wait用法类似,区别是如果没找到直接抛出 XPathElementNotFoundError 异常 el = sl.get() el = sl.get(timeout=15) # 修改默认的等待时间为15s d.xpath.global_set("timeout", 15) d.xpath.implicitly_wait(15) # 与上一行代码等价 (TODO: Removed) print(sl.exists) # 返回是否存在 (bool) sl.get_last_match() # 获取上次匹配的XMLElement sl.get_text() # 获取组件名 sl.set_text("") # 清空输入框 sl.set_text("hello world") # 输入框输入 hello world # 遍历所有匹配的元素 for el in d.xpath('//android.widget.EditText').all(): print("rect:", el.rect) # output tuple: (x, y, width, height) print("center:", el.center()) el.click() # click operation print(el.elem) # 输出lxml解析出来的Node print(el.text) # child操作 d.xpath('@android:id/list').child('/android.widget.TextView').click() 等价于 d.xpath('//*[@resource-id="android:id/list"]/android.widget.TextView').all() ``` 高级查找语法 > Added in version 3.1 ```python # 查找 text=NFC AND id=android:id/item (d.xpath("NFC") & d.xpath("@android:id/item")).get() # 查找 text=NFC OR id=android:id/item (d.xpath("NFC") | d.xpath("App") | d.xpath("Content")).get() # 复杂一点也支持 ((d.xpath("NFC") | d.xpath("@android:id/item")) & d.xpath("//android.widget.TextView")).get() ### `XMLElement`的操作 ```python # 通过XPathSelector.get() 返回的对象叫做 XMLElement el = d.xpath("@com.example:id/home_searchedit").get() lx, ly, width, height = el.rect # 获取左上角坐标和宽高 lx, ly, rx, ry = el.bounds # 左上角与右下角的坐标 x, y = el.center() # get element center position x, y = el.offset(0.5, 0.5) # same as center() # send click el.click() # 打印文本内容 print(el.text) # 获取组内的属性, dict类型 print(el.attrib) # 控件截图 (原理为先整张截图,然后再crop) el.screenshot() # 控件滑动 el.swipe("right") # left, right, up, down el.swipe("right", scale=0.9) # scale默认0.9, 意思是滑动距离为控件宽度的90%, 上滑则为高度的90% print(el.info) # output example {'index': '0', 'text': '', 'resourceId': 'com.example:id/home_searchedit', 'checkable': 'true', 'checked': 'true', 'clickable': 'true', 'enabled': 'true', 'focusable': 'false', 'focused': 'false', 'scrollable': 'false', 'longClickable': 'false', 'password': 'false', 'selected': 'false', 'visibleToUser': 'true', 'childCount': 0, 'className': 'android.widget.Switch', 'bounds': {'left': 882, 'top': 279, 'right': 1026, 'bottom': 423}, 'packageName': 'com.android.settings', 'contentDescription': '', 'resourceName': 'android:id/switch_widget'} ``` ### 滑动到指定位置 > `scroll_to` 这个功能属于新增加的,可能不这么完善(比如不能检测是否滑动到底部了) 先看例子 ```python from uiautomator2 import connect_usb, Direction d = connect_usb() d.scroll_to("下单") d.scroll_to("下单", Direction.FORWARD) # 默认就是向下滑动,除此之外还可以BACKWARD, HORIZ_FORWARD(水平), HORIZ_BACKWARD(水平反向) d.scroll_to("下单", Direction.HORIZ_FORWARD, max_swipes=5) # 除此之外还可以在指定在某个元素内滑动 d.xpath('@com.taobao.taobao:id/dx_root').scroll(Direction.HORIZ_FORWARD) d.xpath('@com.taobao.taobao:id/dx_root').scroll_to("下单", Direction.HORIZ_FORWARD) ``` **比较完整的例子** ```python import uiautomator2 as u2 from uiautomator2 import Direction def main(): d = u2.connect() d.app_start("com.netease.cloudmusic", stop=True) # steps d.xpath("//*[@text='私人FM']/../android.widget.ImageView").click() d.xpath("下一首").click() # 监控弹窗2s钟,时间可能大于2s d.xpath.sleep_watch(2) d.xpath("转到上一层级").click() d.xpath("转到上一层级").click(watch=False) # click without trigger watch d.xpath("转到上一层级").click(timeout=5.0) # wait timeout 5s d.xpath.watch_background() # 开启后台监控模式,默认每4s检查一次 d.xpath.watch_background(interval=2.0) # 每2s检查一次 d.xpath.watch_stop() # 停止监控 for el in d.xpath('//android.widget.EditText').all(): print("rect:", el.rect) # output tuple: (left_x, top_y, width, height) print("bounds:", el.bounds) # output tuple: (left, top, right, bottom) print("center:", el.center()) el.click() # click operation print(el.elem) # 输出lxml解析出来的Node # 滑动 el = d.xpath('@com.taobao.taobao:id/fl_banner_container').get() # 从右滑到左 el.swipe(Direction.HORIZ_FORWARD) el.swipe(Direction.LEFT) # 从右滑到左 # 从下滑到上 el.swipe(Direction.FORWARD) el.swipe(Direction.UP) el.swipe("right", scale=0.9) # scale 默认0.9, 滑动距离为控件宽度的80%, 滑动的中心点与控件中心点一致 el.swipe("up", scale=0.5) # 滑动距离为控件高度的50% # scroll同swipe不一样,scroll返回bool值,表示是否还有新元素出现 el.scroll(Direction.FORWARD) # 向下滑动 el.scroll(Direction.BACKWARD) # 向上滑动 el.scroll(Direction.HORIZ_FORWARD) # 水平向前 el.scroll(Direction.HORIZ_BACKWARD) # 水平向后 if el.scroll("forward"): print("还可以继续滚动") ``` ### `PageSource`对象 > Added in version 3.1 这个属于高级用法,但是这个对象也最初级,几乎所有的函数都依赖它。 什么是PageSource? PageSource是从d.dump_hierarchy()的返回值初始化来的。主要用于通过XPATH完成元素的查找工作。 用法? ```python source = d.xpath.get_page_source() # find_elements 是核心方法 elements = source.find_elements('//android.widget.TextView') # List[XMLElement] for el in elements: print(el.text) # 获取坐标后点击 x, y = elements[0].center() d.click(x, y) # 多种条件的查询写法 es1 = source.find_elements('//android.widget.TextView') es2 = source.find_elements(XPath('@android:id/content').joinpath("//*")) # 寻找是TextView但不属于id=android:id/content下的节点 els = set(es1) - set(es2) # 寻找是TextView同事属于id=android:id/content下的节点 els = set(es1) & set(es2) ``` ## XPath规则 为了写起脚本来更快,我们自定义了一些简化的xpath规则 **规则1** `//` 开头代表原生xpath **规则2** `@` 开头代表resourceId定位 `@smartisanos:id/right_container` 相当于 `//*[@resource-id="smartisanos:id/right_container"]` **规则3** `^`开头代表正则表达式 `^.*道了` 相当于 `//*[re:match(text(), '^.*道了')]` **规则4** > 灵感来自SQL like `知道%` 匹配`知道`开始的文本, 相当于 `//*[starts-with(text(), '知道')]` `%知道` 匹配`知道`结束的文本,相当于 `//*[ends-with(text(), '知道')]` `%知道%` 匹配包含`知道`的文本,相当于 `//*[contains(text(), '知道')]` **规则 Last** 会匹配text 和 description字段 如 `搜索` 相当于 XPath `//*[@text="搜索" or @content-desc="搜索" or @resource-id="搜索"]` ## 特殊说明 - 有时className中包含有`$@#&`字符,这个字符在XML中是不合法的,所以全部替换成了`.` ## XPath的一些高级用法 ``` # 所有元素 //* # resource-id包含login字符 //*[contains(@resource-id, 'login')] # 按钮包含账号或帐号 //android.widget.Button[contains(@text, '账号') or contains(@text, '帐号')] # 所有ImageView中的第二个 (//android.widget.ImageView)[2] # 所有ImageView中的最后一个 (//android.widget.ImageView)[last()] # className包含ImageView //*[contains(name(), "ImageView")] ``` ## 一些有用的网站 - [XPath playground](https://scrapinghub.github.io/xpath-playground/) - [XPath的一些高级用法-简书](https://www.jianshu.com/p/4fef4142b33f) - [XPath Quicksheet](https://devhints.io/xpath) 如有其他资料,欢迎提[Issues](https://github.com/openatx/uiautomator2/issues/new)补充 ================================================ FILE: _archived/aircv/README.md ================================================ # 前言 这是一个 uiautimator2 的一个插件,使得 uiautimator2 可以支持通过图像识别来对手机进行操作 代码集成了开源库: [aircv](https://github.com/NetEaseGame/aircv) # 注意 1. 只能支持常宽比为 16:9 的手机 2. 截图是以 atx-agent 传过来的图像为基准,图片大小为 800*450 因为分辨率小了,会有失真,所以匹配阈值可适当减小(下面有说明) 3. 因为基准图为 800*450 分辨率,area 区域范围不能大于该分辨率(下面有说明) 4. 有时候确实有但是查找不到,或者查找错误,可适当截图截的大一点 # 环境 opencv3.x 1. 安装opencv3 支持py2和py3(测试环境 python2.7.15 和 python3.7.0) ```bash pip install opencv_python ``` 2. 安装 websocket ```bash pip install websocket ``` 3. 安装 numpy ```bash pip install numpy ``` # 设置 ```python # 启用支持网络下载图片选项 Aircv.support_network = True # 默认 False,不启用 # 设置 host,支持 http Aircv.host = "127.0.0.1:8000" # 请求路径,固定 Aircv.path = "/image_service/download/" # 示例,图片请求地址 img_url = "http://127.0.0.1:8000/image_service/download/@img1" # 全局设置操作的超时时间,大于该值时间没有找到图像,会报异常 # timeout 可以在每个函数调用时单独设置 Aircv.timeout = 30 # 全局设置操作的等待时间,该值为在查找到图像后,等待多久再操作(等待UI元素渲染完成) # 例如点击操作,查找到图像后,等待 1秒,然后才点击 Aircv.wait_before_operation = 1 # 全局设置读取图像的频率,间隔几秒读取一张图像,默认为 2秒 # 手机端的服务会 atx-agent 会以较高频率不断发送 800*450 的图像过来,设置该值限制频率 Aircv.rcv_interval = 2 # 图像查找采用模板匹配的方式 # 该设置定义阈值,大于该阈值,则认为图像相同,即找到图像 # 一般来说大于0.999认为图像一样,阈值默认值为0.95 CVHandler.template_threshold = 0.95 ``` # 图像传输 > 一般来说,图像传输会在连接上设备开始传输,程序结束会自动关闭传输 > 如果需要主动关闭,开启图像传输的话,可参考如下 ```python import uiautomator2 as u2 from aircv import Aircv u2.plugin_register('aircv', Aircv) d = u2.connect() # 关闭图像传输 d.ext_aircv.stop_get_scren() # 开启图像传输 d.ext_aircv.start_get_screen() ``` # 示例 ```python import uiautomator2 as u2 from aircv import Aircv u2.plugin_register('aircv', Aircv) d = u2.connect() # 判断是否存在 d.ext_aircv.exists('tmp.jpg') d.ext_aircv.exists('tmp.jpg', timeout=60) # 设置超时时间 # 点击 d.ext_aircv.click('tmp.jpg') d.ext_aircv.click('tmp.jpg', timeout=60) # 设置超时时间 # 原图像中指定查找范围,安卓以左上角为原点即(0,0) # 参数传入左上角坐标和右下角坐标(x1, y1, x2, y2) d.ext_aircv.click('tmp.jpg', area=(100, 100, 300, 200)) # 长按 d.ext_aircv.long_click('tmp.jpg') d.ext_aircv.long_click('tmp.jpg', duration=5) # 设置长按时间 d.ext_aircv.long_click('tmp.jpg', timeout=60) # 设置超时时间 d.ext_aircv.long_click('tmp.jpg', area=(100, 100, 300, 200)) # 设置查找范围 # 滑动 d.ext_aircv.swipe('tmp1.jpg', 'tmp2.jpg') #设置持续时间,0.1 表示持续 1秒, 默认 1秒 d.ext_aircv.swipe('tmp1.jpg', 'tmp2.jpg', duration=0.1) d.ext_aircv.swipe('tmp1.jpg', 'tmp2.jpg', timeout=60) # 设置超时时间 d.ext_aircv.swipe('tmp1.jpg', 'tmp2.jpg', area=(100, 100, 300, 200)) # 设置查找范围 # 多点滑动 # duration 的值, 0.1 表示持续 1秒, 默认 1秒 img_list = ['tmp1.jpg', 'tmp2.jpg', 'tmp3.jpg'] d.ext_aircv.swipe_points(img_list, duration=0.5, timeout=60) # 拖动(按住一会再滑动) d.ext_aircv.drag('tmp1.jpg', 'tmp2.jpg', duration=0.1, timeout=60) d.ext_aircv.drag('tmp1.jpg', 'tmp2.jpg', area=(100, 100, 300, 200)) # 设置查找范围 # 获取坐标(x, y)(返回查找到图像的中心坐标) d.ext_aircv.get_point('tmp1.jpg', timeout=60, area=(100, 100, 300, 200)) ``` ================================================ FILE: _archived/aircv/__init__.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- import threading import time import cv2 import numpy as np import requests import websocket __version__ = "0.0.1" class CVHandler(object): template_threshold = 0.95 # 模板匹配的阈值 def show(self, img): ''' 显示一个图片 ''' cv2.imshow('image', img) cv2.waitKey(0) cv2.destroyAllWindows() def imread(self, filename): ''' Like cv2.imread This function will make sure filename exists ''' im = cv2.imread(filename) if im is None: raise RuntimeError("file: '%s' not exists" % filename) return im def imdecode(self, img_data): ''' Like cv2.imdecode This function will make sure filename exists 直接读取从网络下载的图片数据 ''' im = np.asarray(bytearray(img_data), dtype="uint8") im = cv2.imdecode(im, cv2.IMREAD_COLOR) if im is None: raise RuntimeError("img_data is can not decode") return im def find_template(self, im_source, im_search, threshold=template_threshold, rgb=False, bgremove=False): ''' @return find location if not found; return None ''' result = self.find_all_template(im_source, im_search, threshold, 1, rgb, bgremove) return result[0] if result else None def find_all_template(self, im_source, im_search, threshold=template_threshold, maxcnt=0, rgb=False, bgremove=False): ''' Locate image position with cv2.templateFind Use pixel match to find pictures. Args: im_source(string): 图像、素材 im_search(string): 需要查找的图片 threshold: 阈值,当相识度小于该阈值的时候,就忽略掉 Returns: A tuple of found [(point, score), ...] Raises: IOError: when file read error ''' # method = cv2.TM_CCORR_NORMED # method = cv2.TM_SQDIFF_NORMED method = cv2.TM_CCOEFF_NORMED if rgb: s_bgr = cv2.split(im_search) # Blue Green Red i_bgr = cv2.split(im_source) weight = (0.3, 0.3, 0.4) resbgr = [0, 0, 0] for i in range(3): # bgr resbgr[i] = cv2.matchTemplate(i_bgr[i], s_bgr[i], method) res = resbgr[0] * weight[0] + resbgr[1] * weight[1] + resbgr[2] * weight[2] else: s_gray = cv2.cvtColor(im_search, cv2.COLOR_BGR2GRAY) i_gray = cv2.cvtColor(im_source, cv2.COLOR_BGR2GRAY) # 边界提取(来实现背景去除的功能) if bgremove: s_gray = cv2.Canny(s_gray, 100, 200) i_gray = cv2.Canny(i_gray, 100, 200) res = cv2.matchTemplate(i_gray, s_gray, method) w, h = im_search.shape[1], im_search.shape[0] result = [] while True: min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res) if method in [cv2.TM_SQDIFF, cv2.TM_SQDIFF_NORMED]: top_left = min_loc else: top_left = max_loc if max_val < threshold: break # calculator middle point middle_point = (top_left[0] + w / 2, top_left[1] + h / 2) result.append(dict( result=middle_point, rectangle=(top_left, (top_left[0], top_left[1] + h), (top_left[0] + w, top_left[1]), (top_left[0] + w, top_left[1] + h)), confidence=max_val )) if maxcnt and len(result) >= maxcnt: break # floodfill the already found area cv2.floodFill(res, None, max_loc, (-1000,), max_val - threshold + 0.1, 1, flags=cv2.FLOODFILL_FIXED_RANGE) return result def _sift_instance(self, edge_threshold=100): if hasattr(cv2, 'SIFT'): return cv2.SIFT(edgeThreshold=edge_threshold) return cv2.xfeatures2d.SIFT_create(edgeThreshold=edge_threshold) def sift_count(self, img): sift = self._sift_instance() kp, des = sift.detectAndCompute(img, None) return len(kp) def find_sift(self, im_source, im_search, min_match_count=4): ''' SIFT特征点匹配 ''' res = self.find_all_sift(im_source, im_search, min_match_count, maxcnt=1) if not res: return None return res[0] def find_all_sift(self, im_source, im_search, min_match_count=4, maxcnt=0): ''' 使用sift算法进行多个相同元素的查找 Args: im_source(string): 图像、素材 im_search(string): 需要查找的图片 threshold: 阈值,当相识度小于该阈值的时候,就忽略掉 maxcnt: 限制匹配的数量 Returns: A tuple of found [(point, rectangle), ...] A tuple of found [{"point": point, "rectangle": rectangle, "confidence": 0.76}, ...] rectangle is a 4 points list ''' sift = self._sift_instance() flann = cv2.FlannBasedMatcher({'algorithm': self.FLANN_INDEX_KDTREE, 'trees': 5}, dict(checks=50)) kp_sch, des_sch = sift.detectAndCompute(im_search, None) if len(kp_sch) < min_match_count: return None kp_src, des_src = sift.detectAndCompute(im_source, None) if len(kp_src) < min_match_count: return None h, w = im_search.shape[1:] result = [] while True: # 匹配两个图片中的特征点,k=2表示每个特征点取2个最匹配的点 matches = flann.knnMatch(des_sch, des_src, k=2) good = [] for m, n in matches: # 剔除掉跟第二匹配太接近的特征点 if m.distance < 0.9 * n.distance: good.append(m) if len(good) < min_match_count: break sch_pts = np.float32([kp_sch[m.queryIdx].pt for m in good]).reshape(-1, 1, 2) img_pts = np.float32([kp_src[m.trainIdx].pt for m in good]).reshape(-1, 1, 2) # M是转化矩阵 M, mask = cv2.findHomography(sch_pts, img_pts, cv2.RANSAC, 5.0) matches_mask = mask.ravel().tolist() # 计算四个角矩阵变换后的坐标,也就是在大图中的坐标 h, w = im_search.shape[:2] pts = np.float32([[0, 0], [0, h - 1], [w - 1, h - 1], [w - 1, 0]]).reshape(-1, 1, 2) dst = cv2.perspectiveTransform(pts, M) # trans numpy arrary to python list # [(a, b), (a1, b1), ...] pypts = [] for npt in dst.astype(int).tolist(): pypts.append(tuple(npt[0])) lt, br = pypts[0], pypts[2] middle_point = (lt[0] + br[0]) / 2, (lt[1] + br[1]) / 2 result.append(dict( result=middle_point, rectangle=pypts, confidence=(matches_mask.count(1), len(good)) # min(1.0 * matches_mask.count(1) / 10, 1.0) )) if maxcnt and len(result) >= maxcnt: break # 从特征点中删掉那些已经匹配过的, 用于寻找多个目标 qindexes, tindexes = [], [] for m in good: qindexes.append(m.queryIdx) # need to remove from kp_sch tindexes.append(m.trainIdx) # need to remove from kp_img def filter_index(indexes, arr): r = np.ndarray(0, np.float32) for i, item in enumerate(arr): if i not in qindexes: r = np.append(r, item) return r kp_src = filter_index(tindexes, kp_src) des_src = filter_index(tindexes, des_src) return result def find_all(self, im_source, im_search, maxcnt=0): ''' 优先Template,之后Sift @ return [(x,y), ...] ''' result = self.find_all_template(im_source, im_search, maxcnt=maxcnt) if not result: result = self.find_all_sift(im_source, im_search, maxcnt=maxcnt) if not result: return [] return [match["result"] for match in result] def find(self, im_source, im_search): ''' Only find maximum one object ''' r = self.find_all(im_source, im_search, maxcnt=1) return r[0] if r else None def brightness(self, im): ''' Return the brightness of an image Args: im(numpy): image Returns: float, average brightness of an image ''' im_hsv = cv2.cvtColor(im, cv2.COLOR_BGR2HSV) h, s, v = cv2.split(im_hsv) height, weight = v.shape[:2] total_bright = 0 for i in v: total_bright = total_bright + sum(i) return float(total_bright) / (height * weight) class Aircv(object): timeout = 30 wait_before_operation = 1 # 操作前等待时间 秒 rcv_interval = 2 # 接收图片的间隔时间 秒 # temporary_directory = "./" # 临时保存截图的目录路径 support_network = False # 是否启用网络下载图片 url = "" host = "127.0.0.1:8000" path = "/image_service/download/" def __init__(self, d): self.__rcv_interva_time_cache = 0 self.d = d self.cvHandler = CVHandler() self.FLANN_INDEX_KDTREE = 0 # self.aircv_cache_image_name = Aircv.temporary_directory + self.d._host + "_aircv_cache_image.jpg" self.debug = True self.aircv_cache_image = None self.ws_screen = None self.zoom_out = None # 下面三个函数放在最后,而且顺序不能变 self.detection_screen() self.start_get_screen() self.get_scaling_ratio() def detection_screen(self): """检测设备屏幕比例,必须为 16:9""" display_height = self.d.info['displayHeight'] display_width = self.d.info['displayWidth'] if display_height / display_width != 16 / 9 and display_width / display_height != 16 / 9: raise RuntimeError("Does not support current mobile phones, The screen ratio is not 16:9") def get_scaling_ratio(self): """计算缩放比""" while True: if self.aircv_cache_image is not None: self.zoom_out = 1.0 * self.d.info['displayHeight'] / self.aircv_cache_image.shape[0] break def start_get_screen(self): def on_message(ws, message): this = self if isinstance(message, bytes): if int(time.time()) - this.__rcv_interva_time_cache >= Aircv.rcv_interval: # with open(this.aircv_cache_image_name, 'wb') as f: # f.write(message) # this.aircv_cache_image = this.cvHandler.imread(self.aircv_cache_image_name) this.aircv_cache_image = this.cvHandler.imdecode(message) this.__rcv_interva_time_cache = int(time.time()) def on_error(ws, error): raise RuntimeError(error) def on_close(ws): print("### ws_screen closed ###") def on_open(ws): print("### ws_screen on_open ###") if not self.ws_screen or not self.ws_screen.keep_running: self.ws_screen = websocket.WebSocketApp("ws://" + self.d._host + ":" + str(self.d._port) + "/minicap", on_open=on_open, on_message=on_message, on_error=on_error, on_close=on_close) ws_thread = threading.Thread(target=self.ws_screen.run_forever) ws_thread.daemon = True ws_thread.start() def stop_get_scren(self): if self.ws_screen and self.ws_screen.keep_running: self.ws_screen.close() # operating def find_template_by_crop(self, img, area=None): if Aircv.support_network: img_url = "".join(["http://", Aircv.host, Aircv.path, img]) data = requests.get(img_url) img_serch = self.cvHandler.imdecode(data.content) else: img_serch = self.cvHandler.imread(img) if area: crop_img = self.aircv_cache_image[area[1]:area[3], area[0]:area[2]] result = self.cvHandler.find_template(crop_img, img_serch) point = result['result'] if result else None if point: point = (point[0] + area[0], point[1] + area[1]) else: crop_img = self.aircv_cache_image result = self.cvHandler.find_template(crop_img, img_serch) point = result['result'] if result else None return (int(point[0] * self.zoom_out), int(point[1] * self.zoom_out)) if point else None def exists(self, img, timeout=timeout, area=None): point = None is_exists = False while timeout: if self.debug: print(timeout) if self.aircv_cache_image is not None: point = self.find_template_by_crop(img, area) if point: is_exists = True break else: timeout -= 1 time.sleep(1) return is_exists def click(self, img, timeout=timeout, area=None): point = None while timeout: if self.debug: print(timeout) if self.aircv_cache_image is not None: point = self.find_template_by_crop(img, area) if point: time.sleep(Aircv.wait_before_operation) self.d.click(point[0], point[1]) break else: timeout -= 1 time.sleep(1) if not timeout: raise RuntimeError('No image found') def click_index(self, img, index=1, maxcnt=20, timeout=timeout): point = None img_serch = self.cvHandler.imread(img) while timeout: if self.debug: print(timeout) if self.aircv_cache_image is not None: result_list = self.cvHandler.find_all_template(self.aircv_cache_image, img_serch, maxcnt=maxcnt) point = result_list[index - 1]['result'] if result_list else None if point: time.sleep(Aircv.wait_before_operation) self.d.click(point[0], point[1]) break else: timeout -= 1 time.sleep(1) if not timeout: raise RuntimeError('No image found') def long_click(self, img, duration=None, timeout=timeout, area=None): point = None while timeout: if self.debug: print(timeout) if self.aircv_cache_image is not None: point = self.find_template_by_crop(img, area) if point: time.sleep(Aircv.wait_before_operation) self.d.long_click(point[0], point[1], duration) break else: timeout -= 1 time.sleep(1) if not timeout: raise RuntimeError('No image found') def swipe(self, img_from, img_to, duration=0.1, steps=None, timeout=timeout, area=None): point_from = None point_to = None while timeout: if self.debug: print(timeout) if self.aircv_cache_image is not None: point_from = self.find_template_by_crop(img_from, area) point_to = self.find_template_by_crop(img_to, area) if point_from and point_to: time.sleep(Aircv.wait_before_operation) self.d.swipe(point_from[0], point_from[1], point_to[0], point_to[1], duration, steps) break else: timeout -= 1 time.sleep(1) if not timeout: raise RuntimeError('No image found') def swipe_points(self, img_list, duration=0.5, timeout=timeout, area=None): point_list = [] while timeout: if self.debug: print(timeout) if self.aircv_cache_image is not None: for img in img_list: point = self.find_template_by_crop(img, area) if not point: break point_list.append(point) if len(point_list) == len(img_list): time.sleep(Aircv.wait_before_operation) self.d.swipe_points(point_list, duration) break else: timeout -= 1 time.sleep(1) if not timeout: raise RuntimeError('No image found') def drag(self, img_from, img_to, duration=0.1, steps=None, timeout=timeout, area=None): point_from = None point_to = None while timeout: if self.debug: print(timeout) if self.aircv_cache_image is not None: point_from = self.find_template_by_crop(img_from, area) point_to = self.find_template_by_crop(img_to, area) if point_from and point_to: time.sleep(Aircv.wait_before_operation) self.d.drag(point_from[0], point_from[1], point_to[0], point_to[1], duration, steps) break else: timeout -= 1 time.sleep(1) if not timeout: raise RuntimeError('No image found') def get_point(self, img, timeout=timeout, area=None): point = None while timeout: if self.debug: print(timeout) if self.aircv_cache_image is not None: point = self.find_template_by_crop(img, area) if point: break else: timeout -= 1 time.sleep(1) if not timeout: raise RuntimeError('No image found') return point ================================================ FILE: _archived/init.py ================================================ # coding: utf-8 # import datetime import hashlib import logging import os import shutil import tarfile from pathlib import Path import adbutils import progress.bar import requests from retry import retry from uiautomator2.utils import natualsize from uiautomator2.version import __apk_version__, __atx_agent_version__, __jar_version__, __version__ appdir = os.path.join(os.path.expanduser("~"), '.uiautomator2') GITHUB_BASEURL = "https://github.com/openatx" logger = logging.getLogger(__name__) assets_dir = Path(__file__).absolute().parent.joinpath("assets") class DownloadBar(progress.bar.PixelBar): message = "Downloading" suffix = '%(current_size)s/%(total_size)s' width = 10 @property def total_size(self): return natualsize(self.max) @property def current_size(self): return natualsize(self.index) def gen_cachepath(url: str) -> str: filename = os.path.basename(url) storepath = os.path.join( appdir, "cache", filename.replace(" ", "_") + "-" + hashlib.sha224(url.encode()).hexdigest()[:10], filename) return storepath def cache_download(url, filename=None, timeout=None, storepath=None, logger=logger): """ return downloaded filepath """ # check cache if not filename: filename = os.path.basename(url) if not storepath: storepath = gen_cachepath(url) storedir = os.path.dirname(storepath) if not os.path.isdir(storedir): os.makedirs(storedir) if os.path.exists(storepath) and os.path.getsize(storepath) > 0: logger.debug("Use cached assets: %s", storepath) return storepath logger.debug("Download %s", url) # download from url headers = { 'Accept': '*/*', 'Accept-Encoding': 'gzip, deflate, br', 'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2', 'Connection': 'keep-alive', 'Origin': 'https://github.com', 'User-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36' } # yapf: disable r = requests.get(url, stream=True, headers=headers, timeout=None) r.raise_for_status() file_size = int(r.headers.get("Content-Length")) bar = DownloadBar(filename, max=file_size) with open(storepath + '.part', 'wb') as f: chunk_length = 16 * 1024 while 1: buf = r.raw.read(chunk_length) if not buf: break f.write(buf) bar.next(len(buf)) bar.finish() assert file_size == os.path.getsize(storepath + ".part") # may raise FileNotFoundError shutil.move(storepath + '.part', storepath) return storepath def mirror_download(url: str, filename=None): """ Download from mirror, then fallback to origin url """ storepath = gen_cachepath(url) if not filename: filename = os.path.basename(url) github_host = "https://github.com" if url.startswith(github_host): mirror_url = "https://tool.appetizer.io" + url[len( github_host):] # mirror of github try: return cache_download(mirror_url, filename, timeout=60, storepath=storepath, logger=logger) except (requests.RequestException, FileNotFoundError, AssertionError) as e: logger.debug("download error from mirror(%s), use origin source", e) return cache_download(url, filename, storepath=storepath, logger=logger) def app_uiautomator_apk_urls(): ret = [] for name in ["app-uiautomator.apk", "app-uiautomator-test.apk"]: ret.append((name, "".join([ GITHUB_BASEURL, "/android-uiautomator-server/releases/download/", __apk_version__, "/", name ]))) return ret def parse_apk(path: str): """ Parse APK Returns: dict contains "package" and "main_activity" """ import apkutils2 apk = apkutils2.APK(path) package_name = apk.manifest.package_name main_activity = apk.manifest.main_activity return { "package": package_name, "main_activity": main_activity, } class Initer(): def __init__(self, device: adbutils.AdbDevice, loglevel=logging.DEBUG): d = self._device = device self.sdk = d.getprop('ro.build.version.sdk') self.abi = d.getprop('ro.product.cpu.abi') self.pre = d.getprop('ro.build.version.preview_sdk') self.arch = d.getprop('ro.arch') self.abis = (d.getprop('ro.product.cpu.abilist').strip() or self.abi).split(",") self.__atx_listen_addr = "127.0.0.1:7912" logger.info("uiautomator2 version: %s", __version__) def set_atx_agent_addr(self, addr: str): assert ":" in addr self.__atx_listen_addr = addr @property def atx_agent_path(self): return "/data/local/tmp/atx-agent" def shell(self, *args, timeout=60): logger.debug("Shell: %s", args) return self._device.shell(args, timeout=60) @property def jar_urls(self): """ Returns: iter([name, url], [name, url]) """ for name in ['bundle.jar', 'uiautomator-stub.jar']: yield (name, "".join([ GITHUB_BASEURL, "/android-uiautomator-jsonrpcserver/releases/download/", __jar_version__, "/", name ])) @property def atx_agent_url(self): files = { 'armeabi-v7a': 'atx-agent_{v}_linux_armv7.tar.gz', 'arm64-v8a': 'atx-agent_{v}_linux_arm64.tar.gz', 'armeabi': 'atx-agent_{v}_linux_armv6.tar.gz', 'x86': 'atx-agent_{v}_linux_386.tar.gz', 'x86_64': 'atx-agent_{v}_linux_386.tar.gz', } name = None for abi in self.abis: name = files.get(abi) if name: break if not name: raise Exception( "arch(%s) need to be supported yet, please report an issue in github" % self.abis) return GITHUB_BASEURL + '/atx-agent/releases/download/%s/%s' % ( __atx_agent_version__, name.format(v=__atx_agent_version__)) @property def minicap_urls(self): """ binary from https://github.com/openatx/stf-binaries only got abi: armeabi-v7a and arm64-v8a """ base_url = GITHUB_BASEURL + \ "/stf-binaries/raw/0.3.0/node_modules/@devicefarmer/minicap-prebuilt/prebuilt/" sdk = self.sdk yield base_url + self.abi + "/lib/android-" + sdk + "/minicap.so" yield base_url + self.abi + "/bin/minicap" @property def minitouch_url(self): return ''.join([ GITHUB_BASEURL + "/stf-binaries", "/raw/0.3.0/node_modules/@devicefarmer/minitouch-prebuilt/prebuilt/", self.abi + "/bin/minitouch" ]) @retry(tries=2, logger=logger) def push_url(self, url, dest=None, mode=0o755, tgz=False, extract_name=None): # yapf: disable path = mirror_download(url, filename=os.path.basename(url)) if tgz: tar = tarfile.open(path, 'r:gz') path = os.path.join(os.path.dirname(path), extract_name) tar.extract(extract_name, os.path.dirname(path)) # zlib.error may raise if not dest: dest = "/data/local/tmp/" + os.path.basename(path) logger.debug("Push to %s:0%o", dest, mode) self._device.sync.push(path, dest, mode=mode) return dest def is_apk_outdated(self): """ If apk signature mismatch, the uiautomator test will fail to start command: am instrument -w -r -e debug false \ -e class com.github.uiautomator.stub.Stub \ com.github.uiautomator.test/android.support.test.runner.AndroidJUnitRunner java.lang.SecurityException: Permission Denial: \ starting instrumentation ComponentInfo{com.github.uiautomator.test/android.support.test.runner.AndroidJUnitRunner} \ from pid=7877, uid=7877 not allowed \ because package com.github.uiautomator.test does not have a signature matching the target com.github.uiautomator """ apk_debug = self._device.package_info("com.github.uiautomator") apk_debug_test = self._device.package_info( "com.github.uiautomator.test") logger.debug("apk-debug package-info: %s", apk_debug) logger.debug("apk-debug-test package-info: %s", apk_debug_test) if not apk_debug or not apk_debug_test: return True if apk_debug['version_name'] != __apk_version__: logger.info( "package com.github.uiautomator version %s, latest %s", apk_debug['version_name'], __apk_version__) return True if apk_debug['signature'] != apk_debug_test['signature']: # On vivo-Y67 signature might not same, but signature matched. # So here need to check first_install_time again max_delta = datetime.timedelta(minutes=3) if abs(apk_debug['first_install_time'] - apk_debug_test['first_install_time']) > max_delta: logger.debug( "package com.github.uiautomator does not have a signature matching the target com.github.uiautomator" ) return True return False def is_atx_agent_outdated(self): """ Returns: bool """ agent_version = self._device.shell([self.atx_agent_path, "version"]).strip() if agent_version == "dev": logger.info("skip version check for atx-agent dev") return False # semver major.minor.patch try: real_ver = list(map(int, agent_version.split("."))) want_ver = list(map(int, __atx_agent_version__.split("."))) except ValueError: return True logger.debug("Real version: %s, Expect version: %s", real_ver, want_ver) if real_ver[:2] != want_ver[:2]: return True return real_ver[2] < want_ver[2] def check_install(self): """ Only check atx-agent and test apks (Do not check minicap and minitouch) Returns: True if everything is fine, else False """ d = self._device if d.sync.stat(self.atx_agent_path).size == 0: return False if self.is_atx_agent_outdated(): return False if self.is_apk_outdated(): return False return True def _install_uiautomator_apks(self): """ use uiautomator 2.0 to run uiautomator test 通常在连接USB数据线的情况下调用 """ self.shell("pm", "uninstall", "com.github.uiautomator") self.shell("pm", "uninstall", "com.github.uiautomator.test") for filename, url in app_uiautomator_apk_urls(): path = self.push_url(url, mode=0o644) self.shell("pm", "install", "-r", "-t", path) logger.info("- %s installed", filename) def _install_jars(self): """ use uiautomator 1.0 to run uiautomator test """ for (name, url) in self.jar_urls: self.push_url(url, "/data/local/tmp/" + name, mode=0o644) def _install_atx_agent(self): logger.info("Install atx-agent %s", __atx_agent_version__) if 'armeabi' in self.abis: local_atx_agent_path = assets_dir.joinpath("atx-agent") if local_atx_agent_path.exists(): logger.info("Use local atx-agent[armeabi]: %s", local_atx_agent_path) dest = '/data/local/tmp/atx-agent' self._device.sync.push(local_atx_agent_path, dest, mode=0o755) return self.push_url(self.atx_agent_url, tgz=True, extract_name="atx-agent") def setup_atx_agent(self): # stop atx-agent first self.shell(self.atx_agent_path, "server", "--stop") if self.is_atx_agent_outdated(): self._install_atx_agent() self.shell(self.atx_agent_path, 'server', '--nouia', '-d', "--addr", self.__atx_listen_addr) logger.info("Check atx-agent version") self.check_atx_agent_version() @retry( (requests.ConnectionError, requests.ReadTimeout, requests.HTTPError), delay=.5, tries=10) def check_atx_agent_version(self): port = self._device.forward_port(7912) logger.debug("Forward: local:tcp:%d -> remote:tcp:%d", port, 7912) version = requests.get("http://%s:%d/version" % (self._device._client.host, port)).text.strip() logger.debug("atx-agent version %s", version) wlan_ip = requests.get("http://%s:%d/wlan/ip" % (self._device._client.host, port)).text.strip() logger.debug("device wlan ip: %s", wlan_ip) return version def install(self): """ TODO: push minicap and minitouch from tgz file """ logger.info("Install minicap, minitouch") self.push_url(self.minitouch_url) if self.abi == "x86": logger.info( "abi:x86 not supported well, skip install minicap") elif int(self.sdk) > 30: logger.info("Android R (sdk:30) has no minicap resource") else: for url in self.minicap_urls: self.push_url(url) # self._install_jars() # disable jars if self.is_apk_outdated(): logger.info( "Install com.github.uiautomator, com.github.uiautomator.test %s", __apk_version__) self._install_uiautomator_apks() else: logger.info("Already installed com.github.uiautomator apks") self.setup_atx_agent() print("Successfully init %s" % self._device) def uninstall(self): self._device.shell([self.atx_agent_path, "server", "--stop"]) self._device.shell(["rm", self.atx_agent_path]) logger.info("atx-agent stopped and removed") self._device.shell(["rm", "/data/local/tmp/minicap"]) self._device.shell(["rm", "/data/local/tmp/minicap.so"]) self._device.shell(["rm", "/data/local/tmp/minitouch"]) logger.info("minicap, minitouch removed") self._device.shell(["pm", "uninstall", "com.github.uiautomator"]) self._device.shell(["pm", "uninstall", "com.github.uiautomator.test"]) logger.info("com.github.uiautomator uninstalled, all done !!!") if __name__ == "__main__": import adbutils serial = None device = adbutils.adb.device(serial) init = Initer(device, loglevel=logging.DEBUG) print(init.check_install()) ================================================ FILE: _archived/messagebox.py ================================================ # coding: utf-8 # import time try: import Tkinter as tk except ImportError: import tkinter as tk def retryskipabort(message, timeout=20): """ Show dialog of RETRY,SKIP,ABORT Returns: one of "retry", "skip", "abort" """ root = tk.Tk() root.geometry("400x200") root.title("Exception handle") root.eval('tk::PlaceWindow %s center' % root.winfo_pathname(root.winfo_id())) root.attributes("-topmost", True) _kvs = {"result": "abort"} def cancel_timer(*args): root.after_cancel(_kvs['root']) root.title("Manual") def update_prompt(): cancel_timer() def f(result): def _inner(): _kvs['result'] = result root.destroy() return _inner tk.Label(root, text=message).pack(side=tk.TOP, fill=tk.X, pady=10) frmbtns = tk.Frame(root) tk.Button(frmbtns, text="Skip", command=f('skip')).pack(side=tk.LEFT) tk.Button(frmbtns, text="Retry", command=f('retry')).pack(side=tk.LEFT) tk.Button(frmbtns, text="ABORT", command=f('abort')).pack(side=tk.LEFT) frmbtns.pack(side=tk.BOTTOM) prompt = tk.StringVar() label1 = tk.Label(root, textvariable=prompt) #, width=len(prompt)) label1.pack() deadline = time.time() + timeout def _refresh_timer(): leftseconds = deadline - time.time() if leftseconds <= 0: root.destroy() return root.title("Test will stop after " + str(int(leftseconds)) + " s") _kvs['root'] = root.after(500, _refresh_timer) _kvs['root'] = root.after(0, _refresh_timer) root.bind('', cancel_timer) root.mainloop() return _kvs['result'] if __name__ == '__main__': print(retryskipabort('LKJSDF\nlkjj\what?lkjsdlfjaskdfjlasdkjflnice')) ================================================ FILE: _archived/ocr/README.md ================================================ # 使用百度OCR选取文字元素 ## 前提条件 1.需要有百度云账号,百度云注册账号: https://cloud.baidu.com/?from=console 2.创建一个文字识别的应用: https://console.bce.baidu.com/ai/#/ai/ocr/overview/index 记住三个值 AppID 、API_Key、Secret_Key 3.需要安装百度OCR Python SDK:`pip install baidu-aip` 百度OCR具体应用见百度文档:https://cloud.baidu.com/doc/OCR/s/ejwvxzls6 ## 示例 ```python import uiautomator2 as u2 import uiautomator2.ext.ocr.baiduOCR as ocr APP_ID = '创建应用的APP_ID' API_KEY = '创建应用的API_KEY' SECRECT_KEY = '创建应用的SECRECT_KEY' # options = {"templateSign": ''} # iOCR财会票据识别模板id u2.plugin_add("ocr", ocr.OCR, APP_ID, API_KEY, SECRECT_KEY) # u2.plugin_add("ocrCustom", ocr.OCRCustom, APP_ID, API_KEY, SECRECT_KEY, options) d = u2.connect() d.ext_ocr("对战模式").click() # d.ext_ocrCustom("对战模式").click() ``` ================================================ FILE: _archived/ocr/__init__.py ================================================ # coding: utf-8 # """ import uiautomator2 as u2 import uiautomator2.ext.ocr as ocr u2.plugin_add("ocr", ocr.OCR) d = u2.connect() d.ext_ocr("对战模式").click() """ import time import requests API = "" class OCRObjectNotFound(Exception): pass class OCR(object): def __init__(self, d): """ Args: d: uiautomator2 instance """ self._d = d if not API: raise EnvironmentError("set API var before using OCR") def all(self): rawdata = self._d.screenshot(format='raw') r = requests.post(API, files={"file": ("tmp.jpg", rawdata)}) r.raise_for_status() resp = r.json() assert resp['success'] result = [] for item in resp['data']: lx, ly, rx, ry = item['coords'] x, y = (lx + rx) // 2, (ly + ry) // 2 ocr_text = item['text'] result.append((ocr_text, x, y)) result.sort(key=lambda v: (v[2], v[1])) return result def __call__(self, text): return OCRSelector(self, text) class OCRSelector(object): def __init__(self, server, text=None, textContains=None): self._server = server self._d = server._d self._text = text self._text_contains = textContains def all(self): result = [] for (ocr_text, x, y) in self._server.all(): matched = False if self._text == ocr_text: # exactly match matched = True elif self._text_contains and self._text_contains in ocr_text: matched = True if matched: result.append((ocr_text, x, y)) return result def wait(self, timeout=10): """ Args: timeout: seconds to wait Returns: List of recognition (text, x, y) Raises: OCRObjectNotFound """ deadline = time.time() + timeout first = True while first or time.time() < deadline: first = False all = self.all() if all: return all raise OCRObjectNotFound(self._text) def click(self, timeout=10): result = self.wait(timeout=timeout) _, x, y = result[0] self._d.click(x, y) if __name__ == '__main__': import uiautomator2 as u2 import uiautomator2.ext.ocr as ocr d = u2.connect() print(ocr.OCR(d)("王者峡谷").click()) ================================================ FILE: _archived/ocr/baiduOCR.py ================================================ #!/usr/bin/env python3 """ @version: 1.0.0 @author: rainy008 @description: 使用百度OCR实现截屏选取元素 """ from aip import AipOcr from uiautomator2.ext.ocr import OCR as u2OCR from uiautomator2.ext.ocr import OCRSelector as u2OCRSelector class OCR(u2OCR): def __init__(self, d, app_id, api_key, secrect_key): self._d = d self._APP_ID = app_id self._API_KEY = api_key self._SECRECT_KEY = secrect_key self._client = AipOcr(self._APP_ID, self._API_KEY, self._SECRECT_KEY) def all(self): img = self._d.screenshot(format='raw') resp = self._client.general(img) # 通用文字识别(含位置信息版),每天 500 次免费 result = [] for item in resp['words_result']: left = item['location'].get('left') top = item['location'].get('top') width = item['location'].get('width') height = item['location'].get('height') x, y = left + width // 2, top + height // 2 ocr_text = item['words'] result.append((ocr_text, x, y)) result.sort(key=lambda v: (v[2], v[1])) # print(result) return result def __call__(self, text, exact=True): return OCRSelector(self, text, exact) class OCRSelector(u2OCRSelector): def __init__(self, server, text, exact=True): self._server = server self._d = server._d self._text = text self._exact = exact def all(self): result = [] for (ocr_text, x, y) in self._server.all(): if self._exact and self._text == ocr_text: # exactly match result.append((ocr_text, x, y)) elif self._text in ocr_text: result.append((ocr_text, x, y)) return result def get_text(self, timeout=10): result = self.wait(timeout=timeout) word = result[0][0] return word class OCRCustom(OCR): def __init__(self, d, app_id, api_key, secrect_key, options): super(OCRCustom, self).__init__(d, app_id, api_key, secrect_key) self.options = options def get_words(self): img = self._d.screenshot(format='raw') resp = self._client.custom(img, self.options) # iocr财会票据文字识别(含位置信息版),每天 500 次免费 return resp def all(self): resp = self.get_words() result = [] for item in resp['data']['ret']: left = item['location'].get('left') top = item['location'].get('top') width = item['location'].get('width') height = item['location'].get('height') x, y = left + width // 2, top + height // 2 ocr_text = item['word'] ocr_text_name = item['word_name'] result.append((ocr_text, x, y)) result.append((ocr_text_name, x, y)) result.sort(key=lambda v: (v[2], v[1])) # print(result) return result def get(self, option): """ 返回自定义字段的值 :param option: 自定义的字段,现仅有score和name :return: """ resp = self.get_words() for item in resp['data']['ret']: if item['word_name'] == option: return item['word'] ================================================ FILE: _archived/webview.py ================================================ # coding: utf-8 # # Not implemented yet. # import json import logging import string from pprint import pprint import adbutils import pychrome import requests logger = logging.getLogger(__name__) class WebviewDriver(): def __init__(self, url): self._url = url self._browser = pychrome.Browser(self._url) @property def browser(self): """ new Browser all the time to clear history data """ return self._browser def get_active_tab_list(self): tabs = [] for tab in self.browser.list_tab(): logger.debug("tab: %s", tab) tab.start() t = BrowserTab(tab) if t.is_activate(): tabs.append(t) else: tab.stop() return tabs def get_activate_tab(self): pass class BrowserTab(): def __init__(self, tab): self._tab = tab # I donot know why should call, Runtime.enable() ..., as I know, chromedriver call that. # self._call("Runtime.enable") # self._call("Page.enable") self._evaluate("_C = {}") def is_activate(self): """ is page activate """ height = self._evaluate("window.innerHeight") hidden = self._evaluate("document.hidden") return not hidden and height > 0 def close(self): self._tab.stop() def _evaluate(self, expression, **kwargs): if kwargs: d = {} for k, v in kwargs.items(): d[k] = json.dumps(v) t = string.Template(expression) expression = t.substitute(d) return self._call("Runtime.evaluate", expression=expression) def _call(self, method, **kwargs): logger.debug("call: %s, kwargs: %s", method, kwargs) response = self._tab.call_method(method, **kwargs) logger.debug("response: %s", response) return response.get('result', {}).get('value') def current_url(self): return self._evaluate("window.location.href") def set_current_url(self, url: str): return self._evaluate("""(function(url) { window.location.href = ${url} })""", url=url) def find_element_by_xpath(self, xpath: str): self._evaluate('''(function(xpath){ var obj = document.evaluate(xpath, document, null, XPathResult.ANY_TYPE, null); var button = obj.iterateNext(); _C[1] = button; })($xpath) ''') def coord_by_xpath(self, xpath: str): coord = self._evaluate('''(function(xpath){ var obj = document.evaluate(xpath, document, null, XPathResult.ANY_TYPE, null); var button = obj.iterateNext(); var rect = button.getBoundingClientRect() // [rect.left, rect.top, rect.right, rect.bottom] var x = (rect.left + rect.right)/2 var y = (rect.top + rect.bottom)/2; return JSON.stringify([x, y]) })(${xpath})''', xpath=xpath) return json.loads(coord) def click(self, x, y, duration=0.2, tap_count=1): mills = int(1000*duration) # convert to ms self._call("Input.synthesizeTapGesture", x=x, y=y, duration=mills, tapCount=tap_count) def click_by_xpath(self, xpath): x, y = self.coord_by_xpath(xpath) self.click(x, y) def clear_text_by_xpath(self, xpath): self._evaluate("""(function(xpath){ var obj = document.evaluate(xpath, document, null, XPathResult.ANY_TYPE, null); var button = obj.iterateNext(); button.value = "" })($xpath)""", xpath=xpath) def send_keys(self, text): """ Input text Refs: https://github.com/Tencent/FAutoTest/blob/58766fcb98d135ebb6be88893d10c789a1a50e18/fastAutoTest/core/h5/h5PageOperator.py#L40 http://compatibility.remotedebug.org/Input/Chrome%20(CDP%201.2)/commands/dispatchKeyEvent """ for c in text: self._call("Input.dispatchKeyEvent", type="char", text=c) def screenshot(self): """ always stuck """ raise NotImplementedError() from contextlib import contextmanager from selenium import webdriver @contextmanager def driver(package_name): serial = adbutils.adb.device().serial capabilities = { "androidDeviceSerial": serial, "androidPackage": package_name, "androidUseRunningApp": True, } dr = webdriver.Remote("http://localhost:9515", { "chromeOptions": capabilities }) try: yield dr finally: dr.quit() def chromedriver(): package_name = "io.appium.android.apis" package_name = "com.xueqiu.android" with driver(package_name) as dr: print(dr.current_url) elem = dr.find_element_by_xpath('//*[@id="phone-number"]') elem.click() elem.send_keys("123456") #dr.save_screenshot("s.png" def test_self_driver(): d = adbutils.adb.device() package_name = "com.xueqiu.android" # package_name = "io.appium.android.apis" d.forward("tcp:7912", "tcp:7912") ret = requests.get(f"http://localhost:7912/proc/{package_name}/webview").json() for data in ret: pprint(data) lport = d.forward_port("localabstract:"+data["socketPath"]) wd = WebviewDriver(f"http://localhost:{lport}") tabs = wd.get_active_tab_list() pprint(tabs) for tab in tabs: print(tab.current_url()) tab.click_by_xpath('//*[@id="phone-number"]') tab.clear_text_by_xpath('//*[@id="phone-number"]') tab.send_keys("123456789") break def runtest(): import uiautomator2 as u2 d = u2.connect_usb() pprint(d.request_agent("/webviews").json()) port = d.adb_device.forward_port("localabstract:chrome_devtools_remote") wd = WebviewDriver(f"http://localhost:{port}") tabs = wd.get_active_tab_list() pprint(tabs) def main(): import argparse parser = argparse.ArgumentParser() parser.add_argument("-t", "--test", action="store_true", help="run test_self_driver") args = parser.parse_args() # WebviewDriver() import uiautomator2 as u2 d = u2.connect_usb() assert d.adb_device, "must connect with usb" for socket_path in d.request_agent("/webviews").json(): port = d.adb_device.forward_port("localabstract:"+socket_path) data = requests.get(f"http://localhost:{port}/json/version").json() import pprint pprint.pprint(data) if __name__ == "__main__": main() # if args.test: # print("---- test ----") # test_self_driver() # else: # chromedriver() ================================================ FILE: _archived/widget.py ================================================ # coding: utf-8 # # DEPRECATED # # This file is deprecated and will be removed in the future. import logging import re import time from collections import defaultdict, namedtuple from functools import partial from pprint import pprint from typing import Union import requests from lxml import etree import uiautomator2 as u2 from uiautomator2.image import compare_ssim, draw_point, imread logger = logging.getLogger(__name__) def xml2nodes(xml_content: Union[str, bytes]): if isinstance(xml_content, str): xml_content = xml_content.encode("utf-8") root = etree.fromstring(xml_content) nodes = [] for _, n in etree.iterwalk(root): attrib = dict(n.attrib) if "bounds" in attrib: bounds = re.findall(r"(\d+)", attrib.pop("bounds")) if len(bounds) != 4: continue lx, ly, rx, ry = map(int, bounds) attrib['size'] = (rx - lx, ry - ly) attrib.pop("index", None) ok = False for attrname in ("text", "resource-id", "content-desc"): if attrname in attrib: ok = True break if ok: items = [] for k, v in sorted(attrib.items()): items.append(k + ":" + str(v)) nodes.append('|'.join(items)) return nodes def hierarchy_sim(xml1: str, xml2: str): ns1 = xml2nodes(xml1) ns2 = xml2nodes(xml2) from collections import Counter c1 = Counter(ns1) c2 = Counter(ns2) same_count = sum( [min(c1[k], c2[k]) for k in set(c1.keys()).intersection(c2.keys())]) logger.debug("Same count: %d ns1: %d ns2: %d", same_count, len(ns1), len(ns2)) return same_count / (len(ns1) + len(ns2)) * 2 def read_file_content(filename: str) -> bytes: with open(filename, "rb") as f: return f.read() def safe_xmlstr(s): return s.replace("$", "-") def frozendict(d: dict): items = [] for k, v in sorted(d.items()): items.append(k + ":" + str(v)) return '|'.join(items) CompareResult = namedtuple("CompareResult", ["score", "detail"]) Point = namedtuple("Point", ['x', 'y']) class Widget(object): __domains = { "lo": "http://localhost:17310", } def __init__(self, d: "u2.Device"): self._d = d self._widgets = {} self._compare_results = {} self.popups = [] @property def wait_timeout(self): return self._d.settings['wait_timeout'] def _get_widget(self, id: str): if id in self._widgets: return self._widgets[id] widget_url = self._id2url(id) r = requests.get(widget_url, timeout=3) data = r.json() self._widgets[id] = data return data def _id2url(self, id: str): fields = re.sub("#.*", "", id).split( "/") # remove chars after # and split host and id assert len(fields) <= 2 if len(fields) == 1: return f"http://localhost:17310/api/v1/widgets/{id}" host = self.__domains.get(fields[0]) id = fields[1] # ignore the third part if not re.match("^https?://", host): host = "http://" + host return f"{host}/api/v1/widgets/{id}" def _eq(self, precision: float, a, b): return abs(a - b) < precision def _percent_equal(self, precision: float, a, b, asize, bsize): return abs(a / min(asize) - b / min(bsize)) < precision def _bounds2rect(self, bounds: str): """ Returns: tuple: (lx, ly, width, height) """ if not bounds: return 0, 0, 0, 0 lx, ly, rx, ry = map(int, re.findall(r"\d+", bounds)) return (lx, ly, rx - lx, ry - ly) def _compare_node(self, node_a, node_b, size_a, size_b) -> float: """ Args: node_a, node_b: etree.Element size_a, size_b: tuple size Returns: CompareResult """ result_key = (node_a, node_b) if result_key in self._compare_results: return self._compare_results[result_key] scores = defaultdict(dict) # max 1 if node_a.tag == node_b.tag: scores['class'] = 1 # max 3 for key in ('text', 'resource-id', 'content-desc'): if node_a.attrib.get(key) == node_b.attrib.get(key): scores[key] = 1 if node_a.attrib.get(key) else 0.1 # bounds = node_a.attrib.get("bounds") # pprint(list(map(int, re.findall(r"\d+", bounds)))) ax, ay, aw, ah = self._bounds2rect(node_a.attrib.get("bounds")) bx, by, bw, bh = self._bounds2rect(node_b.attrib.get("bounds")) # max 2 peq = partial(self._percent_equal, 1 / 20, asize=size_a, bsize=size_b) if peq(ax, bx) and peq(ay, by): scores['left_top'] = 1 if peq(aw, bw) and peq(ah, bh): scores['size'] = 1 score = round(sum(scores.values()), 1) result = self._compare_results[result_key] = CompareResult( score, scores) return result def node2string(self, node: etree.Element): return node.tag + ":" + '|'.join([ node.attrib.get(key, "") for key in ["text", "resource-id", "content-desc"] ]) def hybird_compare_node(self, node_a, node_b, size_a, size_b): """ Returns: (scores, results) Return example: 【3.0, 3.2], [CompareResult(score=3.0), CompareResult(score=3.2)] """ cmp_node = partial(self._compare_node, size_a=size_a, size_b=size_b) results = [] results.append(cmp_node(node_a, node_b)) results.append(cmp_node(node_a.getparent(), node_b.getparent())) a_children = node_a.getparent().getchildren() b_children = node_b.getparent().getchildren() if len(a_children) != len(b_children): return results children_result = [] a_children.remove(node_a) b_children.remove(node_b) for i in range(len(a_children)): children_result.append(cmp_node(a_children[i], b_children[i])) results.append(children_result) return results def _hybird_result_to_score(self, obj: Union[list, CompareResult]): """ Convert hybird_compare_node returns to score """ if isinstance(obj, CompareResult): return obj.score ret = [] for item in obj: ret.append(self._hybird_result_to_score(item)) return ret def replace_etree_node_to_class(self, root: etree.ElementTree): for node in root.xpath("//node"): node.tag = safe_xmlstr(node.attrib.pop("class", "") or "node") return root def compare_hierarchy(self, node, root, node_wsize, root_wsize): results = {} for node2 in root.xpath("/hierarchy//*"): result = self.hybird_compare_node(node, node2, node_wsize, root_wsize) results[node2] = result #score return results def etree_fromstring(self, s: str): root = etree.fromstring(s.encode('utf-8')) return self.replace_etree_node_to_class(root) def node_center_point(self, node) -> Point: lx, ly, rx, ry = map(int, re.findall(r"\d+", node.attrib.get("bounds"))) return Point((lx + rx) // 2, (ly + ry) // 2) def match(self, widget: dict, hierarchy=None, window_size: tuple = None): """ Args: widget: widget id hierarchy (optional): current page hierarchy window_size (tuple): width and height Returns: None or MatchResult(point, score, detail, xpath, node, next_result) """ window_size = window_size or self._d.window_size() hierarchy = hierarchy or self._d.dump_hierarchy() w = widget.copy() widget_root = self.etree_fromstring(w['hierarchy']) widget_node = widget_root.xpath(w['xpath'])[0] # 节点打分 target_root = self.etree_fromstring(hierarchy) results = self.compare_hierarchy(widget_node, target_root, w['window_size'], window_size) # yapf: disable # score结构调整 scores = {} for node, result in results.items(): scores[node] = self._hybird_result_to_score(result) # score eg: [3.2, 2.2, [1.0, 1.2]] # 打分排序 nodes = list(scores.keys()) nodes.sort(key=lambda n: scores[n], reverse=True) possible_nodes = nodes[:10] # compare image # screenshot = self._d.screenshot() # for node in possible_nodes: # bounds = node.attrib.get("bounds") # lx, ly, rx, ry = bounds = list(map(int, re.findall(r"\d+", bounds))) # w, h = rx - lx, ry - ly # crop_image = screenshot.crop(bounds) # template = imread(w['target_image']['url']) # try: # score = compare_ssim(template, crop_image) # scores[node][0] += score # except ValueError: # pass # nodes.sort(key=lambda n: scores[n], reverse=True) first, second = nodes[:2] MatchResult = namedtuple( "MatchResult", ["point", "score", "detail", "xpath", "node", "next_result"]) def get_result(node, next_result=None): point = self.node_center_point(node) xpath = node.getroottree().getpath(node) return MatchResult(point, scores[node], results[node], xpath, node, next_result) return get_result(first, get_result(second)) def exists(self, id: str) -> bool: pass def update_widget(self, id, hierarchy, xpath): url = self._id2url(id) r = requests.put(url, json={"hierarchy": hierarchy, "xpath": xpath}) print(r.json()) def wait(self, id: str, timeout=None): """ Args: timeout (float): seconds to wait Returns: None or Result """ timeout = timeout or self.wait_timeout widget = self._get_widget(id) # 获取节点信息 begin_time = time.time() deadline = time.time() + timeout while time.time() < deadline: hierarchy = self._d.dump_hierarchy() hsim = hierarchy_sim(hierarchy, widget['hierarchy']) app = self._d.app_current() is_same_activity = widget['activity'] == app['activity'] if not is_same_activity: print("activity different:", "got", app['activity'], 'expect', widget['activity']) print("hierarchy: %.1f%%" % hsim) print("----------------------") window_size = self._d.window_size() page_ok = False if is_same_activity: if hsim > 0.7: page_ok = True if time.time() - begin_time > 10.0 and hsim > 0.6: page_ok = True if page_ok: result = self.match(widget, hierarchy, window_size) if result.score[0] < 2: time.sleep(0.5) continue if hsim < 0.8: self.update_widget(id, hierarchy, result.xpath) return result time.sleep(1.0) def click(self, id: str, debug: bool = False, timeout=10): print("Click", id) result = self.wait(id, timeout=timeout) if result is None: raise RuntimeError("target not found") x, y = result.point if debug: show_click_position(self._d, Point(x, y)) self._d.click(x, y) # return # while True: # hierarchy = self._d.dump_hierarchy() # hsim = hierarchy_sim(hierarchy, widget['hierarchy']) # app = self._d.app_current() # is_same_activity = widget['activity'] == app['activity'] # print("activity same:", is_same_activity) # print("hierarchy:", hsim) # window_size = self._d.window_size() # if is_same_activity and hsim > 0.8: # result = self.match(widget, hierarchy, window_size) # pprint(result.score) # pprint(result.second.score) # x, y = result.point # self._d.click(x, y) # return # time.sleep(0.1) # return def show_click_position(d: u2.Device, point: Point): # # pprint(result.widget) # # pprint(dict(result.node.attrib)) im = draw_point(d.screenshot(), point.x, point.y) im.show() def main(): d = u2.connect("30.10.93.26") # d.widget.click("00013#推荐歌单第一首") d.widget.exists("lo/00019#播放全部") return d.widget.click("00019#播放全部") # d.widget.click("00018#播放暂停") d.widget.click("00018#播放暂停") d.widget.click("00021#转到上一层级") return d.widget.click("每日推荐") widget_id = "00009#上新" widget_id = "00011#每日推荐" widget_id = "00014#立减20" result = d.widget.match(widget_id) # e = Widget(d) # result = e.match("00003") # print(result) # # e.match("00002") # # result = e.match("00007") wsize = d.window_size() from lxml import etree result = d.widget.match(widget_id) pprint(result.node.attrib) pprint(result.score) pprint(result.detail) show_click_position(d, result.point) return root = etree.parse( '/Users/shengxiang/Projects/weditor/widgets/00010/hierarchy.xml') nodes = root.xpath('/hierarchy/node/node/node/node') a, b = nodes[0], nodes[1] result = d.widget.hybird_compare_node(a, b, wsize, wsize) pprint(result) score = d.widget._hybird_result_to_score(result) pprint(score) return score = d.widget._compare_node(a, b, wsize, wsize) print(score) a, b = nodes[0].getparent(), nodes[1].getparent() score = d.widget._compare_node(a, b, wsize, wsize) pprint(score) return print("score:", result.score) x, y = result.point # # pprint(result.widget) # # pprint(dict(result.node.attrib)) pprint(result.detail) im = draw_point(d.screenshot(), x, y) im.show() if __name__ == "__main__": main() ================================================ FILE: demo_tests/conftest.py ================================================ # coding: utf-8 # author: codeskyblue import pytest import uiautomator2 as u2 @pytest.fixture(scope="function") def d(): _d = u2.connect_usb() _d.press("home") yield _d @pytest.fixture(scope="function") def app(d: u2.Device): d.app_start("com.example.u2testdemo", stop=True) d(text="Addition").wait() yield d ================================================ FILE: demo_tests/test_app.py ================================================ # coding: utf-8 # author: codeskyblue import pytest import uiautomator2 as u2 PACKAGE = "com.example.u2testdemo" def test_wait_activity(d: u2.Device): # assert app.wait_activity('.MainActivity', timeout=10) d.app_start(PACKAGE, activity=".AdditionActivity", wait=True) assert d.wait_activity('.AdditionActivity', timeout=3) assert not d.wait_activity('.NotExistActivity', timeout=1) def test_app_wait(app: u2.Device): assert app.app_wait(PACKAGE, front=True) def test_app_start_stop(d: u2.Device): assert PACKAGE in d.app_list() d.app_stop(PACKAGE) assert PACKAGE not in d.app_list_running() d.app_start(PACKAGE, wait=True) assert PACKAGE in d.app_list_running() def test_app_clear(d: u2.Device): d.app_clear(PACKAGE) # d.app_stop_all() def test_app_info(d: u2.Device): d.app_info(PACKAGE) with pytest.raises(u2.AppNotFoundError): d.app_info("not.exist.package") def test_auto_grant_permissions(d: u2.Device): d.app_auto_grant_permissions(PACKAGE) def test_session(d: u2.Device): app = d.session(PACKAGE) assert app.running() is True assert app.pid > 0 old_pid = app.pid app.restart() assert old_pid != app.pid with app: app(text="Addition").info ================================================ FILE: demo_tests/test_core.py ================================================ # coding: utf-8 # author: codeskyblue from typing import Optional import uiautomator2 as u2 def get_app_process_pid(d: u2.Device) -> Optional[int]: for line in d.shell("ps -u shell").output.splitlines(): fields = line.split() if fields[-1] == 'app_process': pid = fields[1] return int(pid) return None def kill_app_process(d: u2.Device) -> bool: pid = get_app_process_pid(d) if not pid: return False d.shell(f"kill {pid}") return True def test_uiautomator_keeper(d: u2.Device): kill_app_process(d) d.sleep(.2) assert get_app_process_pid(d) is None d.shell('rm /data/local/tmp/u2.jar') d.start_uiautomator() assert get_app_process_pid(d) > 0 d.stop_uiautomator() assert get_app_process_pid(d) is None def test_debug(d: u2.Device): d.debug = True d.info ================================================ FILE: demo_tests/test_device.py ================================================ # coding: utf-8 # author: codeskyblue import random from pathlib import Path import pytest from PIL import Image import uiautomator2 as u2 def test_info(d: u2.Device): d.info d.device_info d.wlan_ip assert isinstance(d.serial, str) w, h = d.window_size() assert w > 0 and h > 0 def test_dump_hierarchy(d: u2.Device): assert d.dump_hierarchy().startswith(" 0 and y > 0 def test_click_exists(app: u2.Device): assert app(text="Addition").click_exists() app(text='Addition').wait_gone() assert not app(text="should-not-exists").click_exists() @pytest.mark.parametrize("direction", ["up", "down", "left", "right"]) def test_swipe(app: u2.Device, direction: str): app(resourceId="android:id/content").swipe(direction) def test_pinch_gesture(app: u2.Device): app(text='Pinch').click() app(description='pinch image').wait() scale_text = app.xpath('Scale%').get_text() assert scale_text.endswith('1.00') app(description='pinch image').pinch_in(80) scale_text = app.xpath('Scale%').get_text() assert scale_text.endswith('0.50') app(description='pinch image').pinch_out() scale_text = app.xpath('Scale%').get_text() assert scale_text.endswith('3.00') app().gesture((0.1, 0.5), (0.9, 0.5), (0.5, 0.5), (0.5, 0.5), steps=20) scale_text = app.xpath('Scale%').get_text() assert scale_text.endswith('0.50') # TODO # long_click # drag_to # swipe # guesture ================================================ FILE: demo_tests/test_watcher.py ================================================ # coding: utf-8 # author: codeskyblue # TODO ================================================ FILE: docs/2to3.md ================================================ # 2.x到3.x升级说明 ## 变更内容简介 - 移除atx-agent,常驻服务移除,改为运行时启动手机内的uiautomator服务 - 直接通过atx-agent地址连接的方法不再支持,connect函数目前只支持本地的USB设备或adb connect状态的设备 - 环境变量 ANDROID_DEVICE_IP 不再支持,如果需要使用环境变量传递序列号可以使用 ANDROID_SERIAL - 版本管理从pbr改为poetry,同时降低依赖库的数量 - Python依赖调整为最低3.8 - 增加更多的单测 - 日志使用标准库logging, 默认不输出任何内容,除非手动开启 - minicap,minitouch不再默认安装 - ~~atx-agent[armeabi]直接打包到lib里面去,避免外网下载依赖~~ ## New - Add function enable_pretty_logging - Add class AdbShellError, HierarchyEmptyError, HTTPError - Add d.xpath.get_page_source() -> PageSource ## Breaking changes (移除的功能) ### Lib removes - Remove logzero - Remove filelocks - Remove progress - Remove packaging - Remove Pillow ### Module removes - Remove module uiautomator2.ext.xpath ### Class removes - Remove class AdbUI - Remove class GatewayError - Remove class ServerError, UiautomatorQuitError, RequestError, UiaError, JsonRpcError, NullObjectExceptionError, NullPointerExceptionError, StaleObjectExceptionError ### Property removes - Remove property u2.logger - Remove property u2.xpath.XPath.logger - Remove property d.watcher.debug - Remove property: address, 原来是用于获取atx-agent的URL地址 - Remove property: alive, 原来用于检测atx-agent的存活状态 - Remove property: uiautomator, 原来用法是d.uiautomator.stop() - Remove property: widget, 原来也不怎么用 - Remove property: http, 原来是这样用的d.http.get("/device_info") - Remove d.settings["xpath_debug"] ### Function removes - Remove function current_app, use app_current instead - Remove function d.xpath.apply_watch_from_yaml - Remove function healcheck, 原来是未来恢复uiautomator服务用的 - Remove function service(name: str) -> Service, 原本是用于做atx-agent的服务管理 - Remove function app_icon() -> Image, 该函数依赖atx-agent - Remove function connect_wifi() -> Device, 该函数依赖atx-agent - Remove function connect_adb_wifi(str) -> Device, 直接用connect就行了 - Remove function set_new_command_timeout(timeout: int), 用不着了 - Remove function open_identify(), 打开一个比较明显的界面,这个函数出了点毛病,先下掉了 - Remove function toast.show(text, duration), 用的不多而且稳定性不好 XPath (d.xpath) methods - remove dump_hierarchy - remove get_last_hierarchy - remove add_event_listener - remove send_click, send_longclick, send_swipe, send_text, take_screenshot - remove when, run_watchers, watch_background, watch_stop, watch_clear, sleep_watch - remove position method, usage like d.xpath(...).position(0.2, 0.2) InputMethod - deprecated wait_fastinput_ime - deprecated set_fastinput_ime use set_input_ime instead ### Command remove - Remove "uiautomator2 healthcheck" - Remove "uiautomator2 identify" ## Function changes ### connect_usb - 2.x connect_usb(serial, init: bool) - 3.x connect_usb(serial) ### shell - 2.x shell(cmdargs, stream: bool, timeout) - 3.x shell(cmdargs, timeout) ### push - 2.x push(src, dst, mode, show_process: bool) - 3.x push(src, dst, mode) ### xpath - 2.x `d.xpath("...").wait() -> XMLElement|None` - 3.x `d.xpath("...").wait() -> bool` ### reset_uiautomator - 2.x reset_uiautomator(self, reason="unknown", depth=0) - 3.x reset_uiautomator() ### app_info 2.x Response ```json { "mainActivity": "com.github.uiautomator.MainActivity", "label": "ATX", "versionName": "1.1.7", "versionCode": 1001007, "size":1760809 } ``` 3.x Response ```json { "versionName": "1.1.7", "versionCode": 1001007, } ``` ### session - 2.x sess = d.session("com.netease.cloudmusic", attach=True, strict=True) - 3.x sess = d.session("com.netease.cloudmusic", attach=True) It seems the strict is useless, so I delete it. ### push - 2.x push(src, dst, mode, show_process:bool=False) - 3.x push(src, dst, mode) ### device_info print(d.device_info) 2.x prints ``` {'udid': '08a3d291-26:17:84:b6:cb:a0-DT1901A', 'version': '10', 'serial': '08a3d291', 'brand': 'SMARTISAN', 'model': 'DT1901A', 'hwaddr': '26:17:84:b6:cb:a0', 'sdk': 29, 'agentVersion': '0.10.0', 'display': {'width': 1080, 'height': 2340}, 'battery': {'acPowered': False, 'usbPowered': True, 'wirelessPowered': False, 'status': 5, 'health': 2, 'present': True, 'level': 100, 'scale': 100, 'voltage': 4356, 'temperature': 292, 'technology': 'Li-poly'}, 'memory': {'total': 7665272, 'around': '7 GB'}, 'cpu': {'cores': 8, 'hardware': 'Qualcomm Technologies, Inc SM8150'}, 'arch': '', 'owner': None, 'presenceChangedAt': '0001-01-01T00:00:00Z', 'usingBeganAt': '0001-01-01T00:00:00Z', 'product': None, 'provider': None} ``` 3.x prints ``` {'serial': 'VVY0223208008426', 'sdk': 31, 'brand': 'HUAWEI', 'model': 'JAD-AL80', 'arch': 'arm64-v8a', 'version': 12} ``` ### app_current - 2.x raise `OSError` if couldn't get focused app - 3.x raise `DeviceError` if couldn't get focused app ### current_ime - 2.x return (ime_method_name, bool), e.g. ("com.github.uiautomator/.FastInputIME", True) - 3.x return ime_method_name, e.g. "com.github.uiautomator/.FastInputIME" ### toast - 2.x d.toast.get_message(5.0, default="") - 3.x d.last_toast (property) - 2.x d.toast.reset() - 3.x d.clear_toast() ================================================ FILE: docs/Makefile ================================================ # Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) html: sphinx-apidoc -o . ../ $(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(0) publish: html @oss_upload.py --directory --prefix uiautomator2-docs _build/html @echo "URL: http://tmallwireless-ycombinator.cn-hangzhou.oss-cdn.aliyun-inc.com/uiautomator2-docs/index.html" .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) ================================================ FILE: docs/conf.py ================================================ # Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # #import os #import sys #sys.path.insert(0, os.path.abspath('../uiautomator2')) import uiautomator2 # -- Project information ----------------------------------------------------- master_doc = 'index' project = 'uiautomator2' copyright = '2020, codeskyblue' author = 'codeskyblue' # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.autosummary', 'sphinx.ext.coverage', 'sphinx.ext.viewcode', ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = 'alabaster' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] ================================================ FILE: examples/adbkit-init/README.md ================================================ # adbkit-init run `python -m uiautomator2 init` once android device plugin. ## Installation 1. Install nodejs 2. Install dependencies by npm ```bash npm install ``` ## Usage ```bash node main.js --server $SERVER_ADDR ``` How it works. Use adbkit to trace device. And the following command will call when device plugin ```bash python -m uiautomator2 init --server $SERVER_ADDR ``` ## LICENSE MIT ================================================ FILE: examples/adbkit-init/main.js ================================================ 'use strict' var Promise = require('bluebird') var adb = require('adbkit') var client = adb.createClient() var util = require('util') const { spawn } = require("child_process") var argv = require('minimist')(process.argv.slice(2)) const serverAddr = argv.server; // Usage: node main.js --server $SERVER_ADDR function initDevice(device) { if (device.type != 'device') { return } client.shell(device.id, 'am start -a android.intent.action.VIEW -d http://www.stackoverflow.com') .then(adb.util.readAll) .then(function(output) { var args = ["-m", "uiautomator2", "init", "--serial", device.id] if (serverAddr) { args.push("--server", serverAddr); } const child = spawn("python", args); child.stdout.on("data", data => { process.stdout.write(data) }) child.stderr.on("data", data => { process.stderr.write(data) }) child.on('close', code => { util.log(`child process exited with code ${code}`); }); }) } util.log("tracking device") if (serverAddr) { util.log("server %s", serverAddr) } client.trackDevices() .then(function(tracker) { tracker.on('add', function(device) { util.log("Device %s(%s) was plugged in", device.id, device.type) initDevice(device) }) tracker.on('remove', function(device) { util.log('Device %s was unplugged', device.id) }) tracker.on("change", function(device) { util.log('Device %s was changed to %s', device.id, device.type) initDevice(device) }) tracker.on('end', function() { util.log('Tracking stopped') }) }) ================================================ FILE: examples/adbkit-init/package.json ================================================ { "name": "adbkit-init", "version": "1.0.0", "description": "run `python -m uiautomator2 init` once android device plugin.", "main": "main.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "author": "", "license": "MIT", "dependencies": { "adbkit": "^2.11.0", "minimist": "^1.2.0" } } ================================================ FILE: examples/apk_install.py ================================================ # coding: utf-8 # # Install problems # # OPPO need password import time import uiautomator2 as u2 def oppo_verify(u): password = "your-password" if u(packageName="com.coloros.safecenter", textContains="请验证身份后安装").exists: print("Auto click install") u.set_fastinput_ime() u(className='android.widget.EditText').set_text(password) u(className='android.widget.Button', text='安装').click() time.sleep(5) u(className='android.widget.Button', text='安装').click() u(className='android.widget.Button', text='完成').click() return True if u(packageName="com.android.packageinstaller", text="重新安装").click_exists(): print("Reinstall") u(className='android.widget.Button', text='安装').click() u(className='android.widget.Button', text='完成').click() return True def main(): u = u2.connect() u.open_identify() u.app_install('https://some-gameapp.apk', installing_callback=oppo_verify) if __name__ == '__main__': main() ================================================ FILE: examples/batteryweb/README.md ================================================ # batteryweb Easy watch device battery # Install ```bash pip install -r requirements.txt ``` # Usage ```bash export FLASK_APP="main.py" export FLASK_DEBUG=1 flask run ``` Demo ![img](https://testerhome.com/uploads/photo/2017/60770b8c-555b-4e54-81f5-ed4facd87ecc.png) ================================================ FILE: examples/batteryweb/main.py ================================================ # coding: utf-8 # import flask import requests app = flask.Flask(__name__) @app.route('/') def index(): return flask.render_template('index.html') @app.route('/battery_level/') def battery_level(ip): r = requests.get('http://'+ip+':7912/info').json() return str(r.get('battery').get('level')) ================================================ FILE: examples/batteryweb/templates/index.html ================================================ Battery 使用前需要安装 uiautomator2
手机IP:
================================================ FILE: examples/com.codeskyblue.remotecamera/main_test.py ================================================ # coding: utf-8 import uiautomator2 as u2 pkg_name = 'com.codeskyblue.remotecamera' d = u2.connect() def setup_function(): d.app_start(pkg_name) def test_simple(): assert d(text="Hello World!").exists ================================================ FILE: examples/com.netease.cloudmusic/README.txt ================================================ 网易云音乐 测试用例 ================ ## P0用例 - 歌曲(播放、暂停、播放) ================================================ FILE: examples/com.netease.cloudmusic/main.py ================================================ # coding: utf-8 import uiautomator2 as u2 def main(): u = u2.connect_usb() u.app_start('com.netease.cloudmusic') u(text='私人FM').click() u(description='转到上一层级').click() u(text='每日推荐').click() u(description='转到上一层级').click() u(text='歌单').click() u(description='转到上一层级').click() u(text='排行榜').click() u(description='转到上一层级').click() if __name__ == '__main__': main() ================================================ FILE: examples/minitouch.py ================================================ # coding: utf-8 # # 半成品 import json from websocket import create_connection from . import Device class Minitouch: # TODO: need test def __init__(self, d: Device): self._d = d self._prepare() def _prepare(self): self._w, self._h = self._d.window_size() uri = self._d.path2url("/minitouch").replace("http:", "ws:") self._ws = create_connection(uri) # self._reset() def down(self, x, y, index: int = 0): px = x / self._w py = y / self._h self._ws_send({"operation": "d", "index": index, "xP": px, "yP": py, "pressure": 0.5}) self._commit() def move(self, x, y, index: int = 0): px = x / self._w py = y / self._h self._ws_send({"operation": "m", "index": index, "xP": px, "yP": py, "pressure": 0.5}) def up(self, x, y, index: int = 0): self._ws_send({"operation": "u", "index": index}) self._commit() def click(self, x, y): self.down(x, y) self.up(x, y) def pinch_in(self, x, y, radius: int, steps: int = 10): """ Args: x, y: center point """ pass def _reset(self): self._ws_send({"operation": "r"}) # reset def _commit(self): self._ws_send({"operation": "c"}) def _ws_send(self, payload: dict): from pprint import pprint pprint(payload) self._ws.send(json.dumps(payload), opcode=1) ================================================ FILE: examples/multi-thread-example.py ================================================ # coding: utf-8 # # GIL limit python multi-thread effectiveness. # But is seems fine, because these operation have so many socket IO # So it seems no need to use multiprocess # import threading import adbutils from logzero import logger import uiautomator2 as u2 def worker(d: u2.Device): d.app_start("io.appium.android.apis", stop=True) d(text="App").wait() for el in d.xpath("@android:id/list").child("/android.widget.TextView").all(): logger.info("%s click %s", d.serial, el.text) el.click() d.press("back") logger.info("%s DONE", d.serial) for dev in adbutils.adb.device_list(): print("Dev:", dev) d = u2.connect(dev.serial) t = threading.Thread(target=worker, args=(d,)) t.start() ================================================ FILE: examples/runyaml/run.py ================================================ #!/usr/bin/env python3 # coding: utf-8 # import argparse import logging import os import re import time import bunch import yaml from logzero import logger import uiautomator2 as u2 CLICK = "click" # swipe SWIPE_UP = "swipe_up" SWIPE_RIGHT = "swipe_right" SWIPE_LEFT = "swipe_left" SWIPE_DOWN = "swipe_down" SCREENSHOT = "screenshot" EXIST = "assert_exist" WAIT = "wait" def split_step(text: str): __alias = { "点击": CLICK, "上滑": SWIPE_UP, "右滑": SWIPE_RIGHT, "左滑": SWIPE_LEFT, "下滑": SWIPE_DOWN, "截图": SCREENSHOT, "存在": EXIST, "等待": WAIT, } for keyword in __alias.keys(): if text.startswith(keyword): body = text[len(keyword):].strip() return __alias.get(keyword, keyword), body else: raise RuntimeError("Step unable to parse", text) def read_file_content(path: str, mode:str = "r") -> str: with open(path, mode) as f: return f.read() def run_step(cf: bunch.Bunch, app: u2.Device, step: str): logger.info("Step: %s", step) oper, body = split_step(step) logger logger = logging.getLogger(__name__).debug("parse as: %s %s", oper, body) if oper == CLICK: app.xpath(body).click() elif oper == SWIPE_RIGHT: app.xpath(body).swipe("right") elif oper == SWIPE_UP: app.xpath(body).swipe("up") elif oper == SWIPE_LEFT: app.xpath(body).swipe("left") elif oper == SWIPE_DOWN: app.xpath(body).swipe("down") elif oper == SCREENSHOT: output_dir = "./output" filename = "screen-%d.jpg" % int(time.time()*1000) if body: filename = body name_noext, ext = os.path.splitext(filename) if ext.lower() not in ['.jpg', '.jpeg', '.png']: ext = ".jpg" os.makedirs(cf.output_directory, exist_ok=True) filename = os.path.join(cf.output_directory, name_noext + ext) logger.debug("Save screenshot: %s", filename) app.screenshot().save(filename) elif oper == EXIST: assert app.xpath(body).wait(), body elif oper == WAIT: #if re.match("^[\d\.]+$") if body.isdigit(): seconds = int(body) logger.info("Sleep %d seconds", seconds) time.sleep(seconds) else: app.xpath(body).wait() else: raise RuntimeError("Unhandled operation", oper) def run_conf(d, conf_filename: str): d.healthcheck() d.xpath.when("允许").click() d.xpath.watch_background(2.0) cf = yaml.load(read_file_content(conf_filename), Loader=yaml.SafeLoader) default = { "output_directory": "output", "action_before_delay": 0, "action_after_delay": 0, "skip_cleanup": False, } for k, v in default.items(): cf.setdefault(k, v) cf = bunch.Bunch(cf) print("Author:", cf.author) print("Description:", cf.description) print("Package:", cf.package) logger.debug("action_delay: %.1f / %.1f", cf.action_before_delay, cf.action_after_delay) app = d.session(cf.package) for step in cf.steps: time.sleep(cf.action_before_delay) run_step(cf, app, step) time.sleep(cf.action_after_delay) if not cf.skip_cleanup: app.close() device = None conf_filename = None def test_entry(): pass if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("-c", "--command", help="run single step command") parser.add_argument("-s", "--serial", help="run single step command") parser.add_argument("conf_filename", default="test.yml", nargs="?", help="config filename") args = parser.parse_args() d = u2.connect(args.serial) if args.command: cf = bunch.Bunch({"output_directory": "output"}) app = d.session() run_step(cf, app, args.command) else: run_conf(d, args.conf_filename) ================================================ FILE: examples/runyaml/test.yml ================================================ --- author: shengxiang.ssx 圣翔 description: 扫一扫测试 package: com.taobao.taobao link: https://aone.xxx.com/xxxx output_directory: output # optional [default "output"] action_before_delay: 0.5 # optional [default 0] action_after_delay: 1 # optional [default 0] skip_cleanup: false # optional [default true] steps: - 等待 我的淘宝 - 点击 扫一扫 - 点击 拍立淘 - 截图 - 存在 扫一扫 ================================================ FILE: examples/test_simple_example.py ================================================ # coding: utf-8 # import uiautomator2 as u2 def test_simple(): d = u2.connect() print(d.info) if __name__ == "__main__": test_simple() ================================================ FILE: examples/u2iniit-standalone/README.txt ================================================ ## atx uiautomator2-init standalone 该版本不用联网下载依赖 ## 使用方法 使用notepad打开`设备初始化.bat` 修改其中的atx-server地址 1. 双击脚本 2. 插入安卓手机,会自动启动初始化步骤。等待控制台出现Init Success表示成功 ## 更新历史 - 2018/03/30 第一版创建 ================================================ FILE: examples/u2iniit-standalone/init-vendor.sh ================================================ #!/bin/bash - # set -e # Verisons modify here ATX_AGENT_VERSION=0.3.0 UIAUTOMATOR_APK_VERSION=1.0.13 # Download resources mkdir -p vendor download_apk(){ APK_BASE_URL="https://github.com/openatx/android-uiautomator-server/releases/download/${UIAUTOMATOR_APK_VERSION}" wget -O vendor/app-uiautomator.apk "$APK_BASE_URL/app-uiautomator.apk" wget -O vendor/app-uiautomator-test.apk "$APK_BASE_URL/app-uiautomator-test.apk" } download_stf(){ ## minicap+minitouch wget -O vendor/stf-binaries.zip "https://github.com/codeskyblue/stf-binaries/archive/master.zip" unzip -o -d vendor/ vendor/stf-binaries.zip } download_atx(){ ## atx-agent wget -O vendor/atx-agent-$ATX_AGENT_VERSION.tar.gz "https://github.com/openatx/atx-agent/releases/download/$ATX_AGENT_VERSION/atx-agent_${ATX_AGENT_VERSION}_linux_armv6.tar.gz" tar -C vendor/ -xzvf vendor/atx-agent-$ATX_AGENT_VERSION.tar.gz atx-agent } download_atx download_apk download_stf echo "Everything is downloaded. ^_^" ================================================ FILE: examples/u2iniit-standalone/main.go ================================================ package main import ( "flag" "fmt" "log" "os" "path/filepath" "strings" "github.com/pkg/errors" goadb "github.com/yosemite-open/go-adb" ) var adb *goadb.Adb const stfBinariesDir = "vendor/stf-binaries-master/node_modules" func init() { var err error adb, err = goadb.New() if err != nil { log.Fatal(err) } serverVersion, err := adb.ServerVersion() if err != nil { log.Fatal(err) } fmt.Printf("adb server version: %d\n", serverVersion) } func initUiAutomator2(device *goadb.Device, serverAddr string) error { props, err := device.Properties() if err != nil { return err } sdk := props["ro.build.version.sdk"] abi := props["ro.product.cpu.abi"] pre := props["ro.build.version.preview_sdk"] // arch := props["ro.arch"] log.Printf("product model: %s\n", props["ro.product.model"]) if pre != "" && pre != "0" { sdk += pre } log.Println("Install minicap and minitouch") if err := initSTFMiniTools(device, abi, sdk); err != nil { return errors.Wrap(err, "mini(cap|touch)") } log.Println("Install app-uiautomator[-test].apk") if err := initUiAutomatorAPK(device); err != nil { return errors.Wrap(err, "app-uiautomator[-test].apk") } log.Println("Install atx-agent") atxAgentPath := "vendor/atx-agent" if err := writeFileToDevice(device, atxAgentPath, "/data/local/tmp/atx-agent", 0755); err != nil { return errors.Wrap(err, "atx-agent") } args := []string{"-d"} if serverAddr != "" { args = append(args, "-t", serverAddr) } output, err := device.RunCommand("/data/local/tmp/atx-agent", args...) output = strings.TrimSpace(output) if err != nil { return errors.Wrap(err, "start atx-agent") } serial, _ := device.Serial() fmt.Println(serial, output) return nil } func writeFileToDevice(device *goadb.Device, src, dst string, mode os.FileMode) error { f, err := os.Open(src) if err != nil { return err } defer f.Close() dstTemp := dst + ".tmp-magic1231x" _, err = device.WriteToFile(dstTemp, f, mode) if err != nil { device.RunCommand("rm", dstTemp) return err } // use mv to prevent "text busy" error _, err = device.RunCommand("mv", dstTemp, dst) return err } func initMiniTouch(device *goadb.Device, abi string) error { srcPath := fmt.Sprintf(stfBinariesDir+"/minitouch-prebuilt/prebuilt/%s/bin/minitouch", abi) return writeFileToDevice(device, srcPath, "/data/local/tmp/minitouch", 0755) } func initSTFMiniTools(device *goadb.Device, abi, sdk string) error { soSrcPath := fmt.Sprintf(stfBinariesDir+"/minicap-prebuilt/prebuilt/%s/lib/android-%s/minicap.so", abi, sdk) err := writeFileToDevice(device, soSrcPath, "/data/local/tmp/minicap.so", 0644) if err != nil { return err } binSrcPath := fmt.Sprintf(stfBinariesDir+"/minicap-prebuilt/prebuilt/%s/bin/minicap", abi) err = writeFileToDevice(device, binSrcPath, "/data/local/tmp/minicap", 0755) if err != nil { return err } touchSrcPath := fmt.Sprintf(stfBinariesDir+"/minitouch-prebuilt/prebuilt/%s/bin/minitouch", abi) return writeFileToDevice(device, touchSrcPath, "/data/local/tmp/minitouch", 0755) } func installAPK(device *goadb.Device, localPath string) error { dstPath := "/data/local/tmp/" + filepath.Base(localPath) if err := writeFileToDevice(device, localPath, dstPath, 0644); err != nil { return err } defer device.RunCommand("rm", dstPath) output, err := device.RunCommand("pm", "install", "-r", "-t", dstPath) if err != nil { return err } if !strings.Contains(output, "Success") { return errors.Wrap(errors.New(output), "apk-install") } return nil } func initUiAutomatorAPK(device *goadb.Device) (err error) { _, er1 := device.StatPackage("com.github.uiautomator") _, er2 := device.StatPackage("com.github.uiautomator.test") if er1 == nil && er2 == nil { log.Println("APK already installed, Skip. Uninstall apk manually if you want to reinstall apk") return } err = installAPK(device, "vendor/app-uiautomator.apk") if err != nil { return } return installAPK(device, "vendor/app-uiautomator-test.apk") } func startService(device *goadb.Device) (err error) { _, err = device.RunCommand("am", "startservice", "-n", "com.github.uiautomator/.Service") return err } func watchAndInit(serverAddr string) { watcher := adb.NewDeviceWatcher() for event := range watcher.C() { if event.CameOnline() { log.Printf("Device %s came online", event.Serial) device := adb.Device(goadb.DeviceWithSerial(event.Serial)) log.Printf("Init device") if err := initUiAutomator2(device, serverAddr); err != nil { log.Printf("Init error: %v", err) continue } else { log.Printf("Init Success") startService(device) } } if event.WentOffline() { log.Printf("Device %s went offline", event.Serial) } } if watcher.Err() != nil { log.Fatal(watcher.Err()) } } func main() { serverAddr := flag.String("server", "", "atx-server address(must be ip:port) eg: 10.0.0.1:7700") flag.Parse() fmt.Println("u2init version 20180330") wd, _ := os.Getwd() log.Println("Add adb.exe to PATH +=", filepath.Join(wd, "vendor")) newPath := fmt.Sprintf("%s%s%s", os.Getenv("PATH"), string(os.PathListSeparator), filepath.Join(wd, "vendor")) os.Setenv("PATH", newPath) watchAndInit(*serverAddr) } ================================================ FILE: examples/u2iniit-standalone/proxyhttp.go ================================================ package main import ( "log" "net/http" "net/http/httputil" "net/url" "github.com/koding/websocketproxy" ) // TODO(ssx): not tested yet. type HTTPWSProxy struct { forwardedAddr string wsProxy *websocketproxy.WebsocketProxy httpProxy *httputil.ReverseProxy } // NewHTTPWSProxy return Proxy instance func NewHTTPWSProxy(forwardedAddr string) *HTTPWSProxy { wsURL, _ := url.Parse("ws://" + forwardedAddr) httpURL, _ := url.Parse("http://" + forwardedAddr) return &HTTPWSProxy{ forwardedAddr: forwardedAddr, wsProxy: websocketproxy.NewProxy(wsURL), httpProxy: httputil.NewSingleHostReverseProxy(httpURL), } } func (p *HTTPWSProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Upgrade") == "websocket" { log.Println("proxy websocket", r.RequestURI) p.wsProxy.ServeHTTP(w, r) return } log.Println("proxy http", r.RequestURI) p.httpProxy.ServeHTTP(w, r) } ================================================ FILE: examples/u2iniit-standalone/uiautomator2-init-standalone.bat ================================================ @echo off echo "ֻԶʼ" u2init.exe rem Ҫatx-server, עӵ rem u2init.exe -server "REPLACE-ATX-SERVER-ADDR-HERE" pause ================================================ FILE: mobile_tests/conftest.py ================================================ # coding: utf-8 import adbutils import pytest import uiautomator2 as u2 @pytest.fixture(scope="module") def d(device): _d = device _d.settings['operation_delay'] = (0.2, 0.2) _d.settings['operation_delay_methods'] = ['click', 'swipe'] return _d @pytest.fixture def package_name(): return "io.appium.android.apis" @pytest.fixture(scope="function") def dev(d: u2.Device, package_name) -> u2.Device: # type: ignore d.watcher.reset() d.app_start(package_name, stop=True) yield d # run parallel # py.test --tx "3*popen" --dist=load test_device.py -q --tb=line #def read_device_list() -> list: # return [v.serial for v in adbutils.adb.device_list()] #def pytest_configure(config): # # read device list if we are on the master # if not hasattr(config, "slaveinput"): # config.devlist = read_device_list() # def pytest_configure_node(node): # # the master for each node fills slaveinput dictionary # # which pytest-xdist will transfer to the subprocess # serial = node.slaveinput["serial"] = node.config.devlist.pop() # node.config.devlist.insert(0, serial) @pytest.fixture(scope="session") def device(request): return u2.connect() # slaveinput = getattr(request.config, "slaveinput", None) # if slaveinput is None: # single-process execution # serial = read_device_list()[0] # else: # running in a subprocess here # serial = slaveinput["serial"] # print("SERIAL:", serial) # return u2.connect(serial) ================================================ FILE: mobile_tests/runtest.sh ================================================ #!/bin/bash # set -e if [[ $# -eq 0 ]] then URL="https://github.com/appium/java-client/raw/v7.3.0/src/test/java/io/appium/java_client/ApiDemos-debug.apk" python3 -m adbutils -i "$URL" #https://github.com/appium/java-client/raw/master/src/test/java/io/appium/java_client/ApiDemos-debug.apk fi py.test -v "$@" ================================================ FILE: mobile_tests/skip_test_image.py ================================================ # coding: utf-8 # import os import cv2 import numpy as np import pytest from PIL import Image import uiautomator2.image as u2image TESTDIR = os.path.dirname(os.path.abspath(__file__)) + "/testdata" # if set to DIR__, pytest will fail with TypeError: 'str' object is not callable @pytest.fixture def path_ae86(): filepath = os.path.join(TESTDIR, "./AE86.jpg") return filepath @pytest.fixture def im_ae86(path_ae86: str) -> np.ndarray: """ 使用opencv打开的图片 """ im = cv2.imread(path_ae86) return im def test_imread(im_ae86, path_ae86): # Path im = u2image.imread(path_ae86) assert im.shape == (193, 321, 3) # URL im = u2image.imread("https://www.baidu.com/img/bd_logo1.png") assert im.shape == (258, 540, 3) # Opencv im = u2image.imread(im_ae86) assert im.shape == (193, 321, 3), "图片格式变化" # PIL.Image pilim = Image.open(path_ae86) im = u2image.imread(pilim) assert pilim.size == (321, 193) assert im.shape == (193, 321, 3), "图片格式变化" @pytest.mark.skip("missing test images") def test_image_match(): class MockDevice(): def __init__(self): self.x = None self.y = None def click(self, x, y): self.x = x self.y = y def screenshot(self, *args, **kwargs): return cv2.imread(TESTDIR + "/screenshot.jpg") d = MockDevice() ix = u2image.ImageX(d) template = Image.open(TESTDIR + "/template.jpg") res = ix.match(template) x, y = res['point'] assert (x, y) == (409, 659), "Match position is wrong" ix.click(template) assert d.x == 409 assert d.y == 659 if False: # show position pim = Image.open(TESTDIR+"/screenshot.jpg") nim = u2image.draw_point(pim, x, y) nim.show() ================================================ FILE: mobile_tests/test_push_pull.py ================================================ # coding: utf-8 # import io import os import uiautomator2 as u2 def test_push_and_pull(d: u2.Device): device_target = "/data/local/tmp/hello.txt" content = b"hello world" d.push(io.BytesIO(content), device_target) d.pull(device_target, "tmpfile-hello.txt") with open("tmpfile-hello.txt", "rb") as f: assert f.read() == content os.unlink("tmpfile-hello.txt") ================================================ FILE: mobile_tests/test_screenrecord.py ================================================ # coding: utf-8 # import time import pytest import uiautomator2 as u2 @pytest.mark.skip("deprecated") def test_screenrecord(d: u2.Device): import imageio with pytest.raises(RuntimeError): d.screenrecord.stop() d.screenrecord("output.mp4", fps=10) start = time.time() with pytest.raises(RuntimeError): d.screenrecord("output2.mp4") time.sleep(3.0) d.screenrecord.stop() print("Time used:", time.time() - start) # check with imageio.get_reader("output.mp4") as f: meta = f.get_meta_data() assert isinstance(meta, dict) from pprint import pprint pprint(meta) ================================================ FILE: mobile_tests/test_session.py ================================================ # coding: utf-8 # import pytest import uiautomator2 as u2 from uiautomator2.exceptions import SessionBrokenError def test_session_function_exists(dev: u2.Device): dev.wlan_ip dev.watcher dev.jsonrpc dev.shell dev.settings dev.xpath def test_app_mixin(dev: u2.Device, package_name: str): assert package_name in dev.app_list() dev.app_stop(package_name) assert package_name not in dev.app_list_running() dev.app_start(package_name) assert package_name in dev.app_list_running() demo_pid = dev.app_wait(package_name) current_info = dev.app_current() assert demo_pid == current_info['pid'] assert current_info['package'] == package_name dev.app_start(package_name, stop=True) assert demo_pid != dev.app_wait(package_name) def test_session_app(dev: u2.Device, package_name): dev.app_start(package_name) assert dev.app_current()['package'] == package_name dev.app_wait(package_name) assert package_name in dev.app_list() assert package_name in dev.app_list_running() with dev.session("io.appium.android.apis") as sess: sess(text="App").click() assert sess.running() is True dev.app_stop("io.appium.android.apis") assert sess.running() is False with pytest.raises(SessionBrokenError): sess(text="App").click() with dev.session("io.appium.android.apis") as sess: sess(text="App").click() assert sess.running() is True def test_session_window_size(dev: u2.Device): assert isinstance(dev.window_size(), tuple) def test_auto_grant_permissions(dev: u2.Device): dev.app_auto_grant_permissions("io.appium.android.apis") ================================================ FILE: mobile_tests/test_settings.py ================================================ # coding: utf-8 # import time import pytest import uiautomator2 as u2 def test_set_xpath_debug(dev: u2.Device): with pytest.raises(TypeError): dev.settings['xpath_debug'] = 1 dev.settings['xpath_debug'] = True assert dev.settings['xpath_debug'] == True dev.settings['xpath_debug'] = False assert dev.settings['xpath_debug'] == False def test_wait_timeout(d: u2.Device): d.settings['wait_timeout'] = 19.0 assert d.wait_timeout == 19.0 d.settings['wait_timeout'] = 10 assert d.wait_timeout == 10 d.implicitly_wait(15) assert d.settings['wait_timeout'] == 15 def test_operation_delay(dev: u2.Session): x, y = dev(text="App").center() # 测试前延迟 start = time.time() dev.settings['operation_delay'] = (1, 0) dev.click(x, y) time_used = time.time() - start assert 1 < time_used < 1.5 # 测试后延迟 start = time.time() dev.settings['operation_delay_methods'] = ['press', 'click'] dev.settings['operation_delay'] = (0, 2) dev.press("back") time_used = time.time() - start # assert time_used > 2 #2 < time_used < 2.5 # 测试operation_delay_methods start = time.time() dev.settings['operation_delay_methods'] = ['press'] # dev.jsonrpc = Mock() dev.click(x, y) time_used = time.time() - start # assert 0 < time_used < 0.5 ================================================ FILE: mobile_tests/test_simple.py ================================================ # coding: utf-8 # # Test apk Download from # https://github.com/appium/java-client/raw/master/src/test/java/io/appium/java_client/ApiDemos-debug.apk import time import unittest import pytest import uiautomator2 as u2 @pytest.mark.skip("not working") def test_toast_get_message(dev: u2.Device): d = dev assert d.toast.get_message(0) is None assert d.toast.get_message(0, default="d") == "d" d(text="App").click() d(text="Notification").click() d(text="NotifyWithText").click() try: d(text="Show Short Notification").click() except u2.UiObjectNotFoundError: d(text="SHOW SHORT NOTIFICATION").click() #self.assertEqual(d.toast.get_message(2, 5, ""), "Short notification") assert "Short notification" in d.toast.get_message(2, 5, "") time.sleep(.5) assert d.toast.get_message(0, 0.4) def test_scroll(dev: u2.Device): d = dev d(text="App").click() if not d(scrollable=True).exists: pytest.skip("screen to large, no need to scroll") d(scrollable=True).scroll.to(text="Voice Recognition") @pytest.mark.skip("Need upgrade") def test_watchers(self): """ App -> Notification -> Status Bar """ d = self.sess d.watcher.remove() d.watcher.stop() d(text="App").click() d.xpath("Notification").wait() d.watcher("N").when('Notification').click() d.watcher.run() self.assertTrue(d(text="Status Bar").wait(timeout=3)) d.press("back") d.press("back") # Should auto click Notification when show up self.assertFalse(d.watcher.running()) d.watcher.start() self.assertTrue(d.watcher.running()) d(text="App").click() self.assertTrue(d(text="Status Bar").exists(timeout=5)) d.watcher.remove("N") d.press("back") d.press("back") d(text="App").click() self.assertFalse(d(text="Status Bar").wait(timeout=5)) @pytest.mark.skip("TODO:: not fixed") def test_count(self): d = self.sess count = d(resourceId="android:id/list").child( className="android.widget.TextView").count self.assertEqual(count, 11) self.assertEqual( d(resourceId="android:id/list").info['childCount'], 11) count = d(resourceId="android:id/list").child( className="android.widget.TextView", instance=0).count self.assertEqual(count, 1) def test_get_text(dev): d = dev text = d(resourceId="android:id/list").child( className="android.widget.TextView", text="App").get_text() assert text == "App" def test_xpath(dev): d = dev d.xpath("//*[@text='Media']").wait() assert len(d.xpath("//*[@text='Media']").all()) == 1 assert len(d.xpath("//*[@text='MediaNotExists']").all()) == 0 d.xpath("//*[@text='Media']").click() assert d.xpath('//*[contains(@text, "VideoView")]').wait(5) @pytest.mark.skip("Need fix") def test_implicitly_wait(d): d.implicitly_wait(2) assert d.implicitly_wait() == 2 start = time.time() with self.assertRaises(u2.UiObjectNotFoundError): d(text="Sensors").get_text() time_used = time.time() - start assert time_used >= 2 # maybe longer then 2, waitForExists -> getText # getText may take 1~2s assert time_used < 2 + 3 @pytest.mark.skip("TODO:: not fixed") def test_select_iter(d): d(text='OS').click() texts = d(resourceId='android:id/list').child( className='android.widget.TextView') assert texts.count == 4 words = [] for item in texts: words.append(item.get_text()) assert words == ['Morse Code', 'Rotation Vector', 'Sensors', 'SMS Messaging'] @pytest.mark.skip("Deprecated") def test_plugin(self): def _my_plugin(d, k): def _inner(): return k return _inner u2.plugin_clear() u2.plugin_register('my', _my_plugin, 'pp') self.assertEqual(self.d.ext_my(), 'pp') def test_send_keys(dev): d = dev d.xpath("App").click() d.xpath("Search").click() d.xpath('//*[@text="Invoke Search"]').click() d.xpath('@io.appium.android.apis:id/txt_query_prefill').click() d.send_keys("hello", clear=True) assert d.xpath('io.appium.android.apis:id/txt_query_prefill').info['text'] == 'hello' if __name__ == '__main__': unittest.main() ================================================ FILE: mobile_tests/test_swipe.py ================================================ # coding: utf-8 # import time import uiautomator2 as u2 def test_swipe_duration(d: u2.Device): w, h = d.window_size() start = time.time() d.swipe(w//2, h//2, w-1, h//2, 2.0) duration = time.time() - start assert duration >= 1.5 # actually duration is about 7s in my TT ================================================ FILE: mobile_tests/test_watcher.py ================================================ # coding: utf-8 # import uiautomator2 as u2 def test_watch_context(dev: u2.Device): with dev.watch_context(builtin=True) as ctx: ctx.when("App").click() dev(text='Menu').click() assert dev(text='Inflate from XML').wait() def teardown_function(d: u2.Device): print("Teardown", d) ================================================ FILE: mobile_tests/test_xpath.py ================================================ # coding: utf-8 # import threading from functools import partial import pytest import uiautomator2 as u2 def test_get_text(dev: u2.Device): assert dev.xpath("App").get_text() == "App" def test_click(dev: u2.Device): dev.xpath("App").click() assert dev.xpath("Alarm").wait() assert dev.xpath("Alarm").exists def test_swipe(dev: u2.Device): d = dev d.xpath("App").click() d.xpath("Alarm").wait() # assert not d.xpath("Voice Recognition").exists d.xpath("@android:id/list").get().swipe("up", 0.5) assert d.xpath("Voice Recognition").wait() def test_xpath_query(dev: u2.Device): assert dev.xpath("Accessibility").wait() assert dev.xpath("%ccessibility").wait() assert dev.xpath("Accessibilit%").wait() def test_element_all(dev: u2.Device): app = dev.xpath('//*[@text="App"]') assert app.wait() assert len(app.all()) == 1 assert app.exists def test_watcher(dev: u2.Device, request): dev.watcher.when("App").click() dev.watcher.start(interval=1.0) event = threading.Event() def _set_event(e): e.set() dev.watcher.when("Action Bar").call(partial(_set_event, event)) assert event.wait(5.0), "xpath not trigger callback" def test_xpath_scroll_to(dev: u2.Device): d = dev d.xpath("Graphics").click() d.xpath("@android:id/list").scroll_to("Pictures") assert d.xpath("Pictures").exists def test_xpath_parent(dev: u2.Device): d = dev info = d.xpath("App").parent("@android:id/list").info assert info["resourceId"] == "android:id/list" ================================================ FILE: poetry.toml ================================================ [virtualenvs] create = true in-project = true ================================================ FILE: pyproject.toml ================================================ [tool.poetry] name = "uiautomator2" version = "3.2.0" description = "uiautomator for android device" homepage = "https://github.com/openatx/uiautomator2" authors = ["codeskyblue "] license = "MIT" readme = "README.md" include = ["*/assets/*"] [tool.poetry.dependencies] python = "^3.8" requests = "*" lxml = "*" adbutils = ">=2.11.0,<3" Pillow = "*" retry2 = "^0.9.5" importlib-resources = {version = "*", markers = "python_version < \"3.9\""} [tool.poetry.group.dev.dependencies] pytest = "^8.1.1" isort = "^5.13.2" pytest-cov = "^4.1.0" ipython = "*" coverage = "^7.6.0" [tool.poetry.scripts] uiautomator2 = "uiautomator2.__main__:main" [tool.poetry-dynamic-versioning] # 根据tag来动态配置版本号 enable = true pattern = "^((?P\\d+)!)?(?P\\d+(\\.\\d+)*)" [tool.poetry-dynamic-versioning.substitution] files = ["uiautomator2/version.py"] [build-system] requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] build-backend = "poetry_dynamic_versioning.backend" ================================================ FILE: tests/test_core.py ================================================ # coding: utf-8 # import hashlib from unittest.mock import Mock, mock_open, patch import pytest from uiautomator2.core import BasicUiautomatorServer @pytest.fixture def mock_server(): """Create a mock BasicUiautomatorServer instance with a mock device""" mock_dev = Mock() with patch.object(BasicUiautomatorServer, '__init__', return_value=None): server = BasicUiautomatorServer(None) server._dev = mock_dev yield server, mock_dev class TestCheckDeviceFileHash: """Test the _check_device_file_hash method with toybox fallback""" def test_toybox_md5sum_success(self, mock_server): """Test when toybox md5sum command works correctly""" server, mock_dev = mock_server # Create a temporary file with known content test_content = b"test content for md5" local_md5 = hashlib.md5(test_content).hexdigest() # Mock the shell command to return toybox md5sum output # Format: "md5hash filename" mock_dev.shell.return_value = f"{local_md5} /data/local/tmp/u2.jar" # Mock the file read to return our test content with patch("builtins.open", mock_open(read_data=test_content)): result = server._check_device_file_hash("test.jar", "/data/local/tmp/u2.jar") # Verify the result is True (hash matches) assert result is True # Verify toybox md5sum was called mock_dev.shell.assert_called_once_with(["toybox", "md5sum", "/data/local/tmp/u2.jar"]) def test_toybox_not_found_fallback_to_md5(self, mock_server): """Test fallback to md5 command when toybox is not found""" server, mock_dev = mock_server # Create a temporary file with known content test_content = b"test content for md5" local_md5 = hashlib.md5(test_content).hexdigest() # Mock the shell command to return different outputs # First call: toybox not found # Second call: md5 command output (format: "MD5 (filename) = md5hash") mock_dev.shell.side_effect = [ "toybox: not found", f"MD5 (/data/local/tmp/u2.jar) = {local_md5}" ] # Mock the file read to return our test content with patch("builtins.open", mock_open(read_data=test_content)): result = server._check_device_file_hash("test.jar", "/data/local/tmp/u2.jar") # Verify the result is True (hash matches) assert result is True # Verify both commands were called assert mock_dev.shell.call_count == 2 assert mock_dev.shell.call_args_list[0][0][0] == ["toybox", "md5sum", "/data/local/tmp/u2.jar"] assert mock_dev.shell.call_args_list[1][0][0] == ["md5", "/data/local/tmp/u2.jar"] def test_hash_mismatch(self, mock_server): """Test when the hash doesn't match""" server, mock_dev = mock_server # Create a temporary file with known content test_content = b"test content for md5" different_md5 = hashlib.md5(b"different content").hexdigest() # Mock the shell command to return a different hash mock_dev.shell.return_value = f"{different_md5} /data/local/tmp/u2.jar" # Mock the file read to return our test content with patch("builtins.open", mock_open(read_data=test_content)): result = server._check_device_file_hash("test.jar", "/data/local/tmp/u2.jar") # Verify the result is False (hash doesn't match) assert result is False def test_md5_command_also_fails(self, mock_server): """Test when both toybox and md5 commands fail to find the file""" server, mock_dev = mock_server # Create a temporary file with known content test_content = b"test content for md5" # Mock the shell command to return errors for both commands mock_dev.shell.side_effect = [ "toybox: not found", "md5: /data/local/tmp/u2.jar: No such file or directory" ] # Mock the file read to return our test content with patch("builtins.open", mock_open(read_data=test_content)): result = server._check_device_file_hash("test.jar", "/data/local/tmp/u2.jar") # Verify the result is False (file not found on device) assert result is False ================================================ FILE: tests/test_import.py ================================================ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """Created on Wed Mar 20 2024 14:51:03 by codeskyblue """ import uiautomator2 as u2 def test_import(): u2.Device u2.connect u2.connect_usb u2.Device.app_install u2.Device.app_uninstall u2.Device.app_current u2.Device.app_list u2.Device.shell u2.Device.send_keys u2.Device.click u2.Device.swipe u2.Device.dump_hierarchy u2.Device.freeze_rotation u2.Device.open_notification u2.Device.info u2.Device.xpath u2.Device.clipboard u2.Device.orientation ================================================ FILE: tests/test_input.py ================================================ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """Tests for input method functionality""" from unittest.mock import Mock, patch import pytest from uiautomator2._input import InputMethodMixIn from uiautomator2.exceptions import AdbBroadcastError class MockInputMethodMixIn(InputMethodMixIn): """Mock implementation for testing""" def __init__(self): self._adb_device = Mock() self._jsonrpc = Mock() self._broadcast_calls = [] self._shell_calls = [] self._current_ime = 'com.github.uiautomator/.AdbKeyboard' @property def adb_device(self): return self._adb_device @property def jsonrpc(self): return self._jsonrpc def shell(self, args): """Mock shell method""" self._shell_calls.append(args) result = Mock() result.output = self._current_ime return result def _broadcast(self, action, extras=None): """Mock broadcast method""" from uiautomator2._input import BORADCAST_RESULT_OK, BroadcastResult self._broadcast_calls.append((action, extras or {})) return BroadcastResult(BORADCAST_RESULT_OK, "success") def __call__(self, **kwargs): """Mock selector call for fallback""" if not hasattr(self, '_mock_element'): self._mock_element = Mock() self._mock_element.set_text = Mock(return_value=True) return self._mock_element def test_send_keys_hides_keyboard_when_using_custom_ime(): """Test that send_keys hides keyboard after successful input with custom IME""" mock_input = MockInputMethodMixIn() # Test successful send_keys with custom IME result = mock_input.send_keys("hello world") # Should return True for successful operation assert result is True # Check broadcast calls - should have both input and hide calls broadcast_calls = mock_input._broadcast_calls assert len(broadcast_calls) >= 2 # First call should be for input assert broadcast_calls[0][0] == "ADB_KEYBOARD_INPUT_TEXT" assert "text" in broadcast_calls[0][1] # Last call should be for hiding keyboard assert broadcast_calls[-1][0] == "ADB_KEYBOARD_HIDE" assert broadcast_calls[-1][1] == {} def test_send_keys_fallback_does_not_hide_keyboard(): """Test that send_keys fallback to set_text does not hide keyboard""" mock_input = MockInputMethodMixIn() # Mock the _must_broadcast to raise AdbBroadcastError for input text def failing_must_broadcast(action, extras=None): if action == "ADB_KEYBOARD_INPUT_TEXT": raise AdbBroadcastError("Simulated failure for input text") # Should not reach here (keyboard hide) in fallback mode raise AdbBroadcastError(f"Unexpected broadcast call: {action}") mock_input._must_broadcast = failing_must_broadcast # Test fallback behavior with patch('warnings.warn'): # Suppress warning output result = mock_input.send_keys("hello world") # Should return the result from set_text (True in our mock) assert result is True # The element's set_text should have been called mock_element = mock_input(focused=True) assert mock_element.set_text.called mock_element.set_text.assert_called_with("hello world") def test_hide_keyboard_method(): """Test the hide_keyboard method directly""" mock_input = MockInputMethodMixIn() mock_input.hide_keyboard() # Should have made broadcast call for hiding broadcast_calls = mock_input._broadcast_calls assert len(broadcast_calls) >= 1 assert broadcast_calls[-1][0] == "ADB_KEYBOARD_HIDE" assert broadcast_calls[-1][1] == {} ================================================ FILE: tests/test_logger.py ================================================ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """Created on Thu Apr 04 2024 16:57:34 by codeskyblue """ import logging import pytest from uiautomator2 import enable_pretty_logging def test_enable_pretty_logging(caplog: pytest.LogCaptureFixture): logger = logging.getLogger("uiautomator2") logger.info("should not be printed") enable_pretty_logging() logger.info("hello") enable_pretty_logging(logging.INFO) logger.info("world") logger.debug("should not be printed") # Use caplog.text to check the entire log output as a single string assert "hello" in caplog.text assert "world" in caplog.text assert "should not be printed" not in caplog.text ================================================ FILE: tests/test_settings.py ================================================ # coding: utf-8 # author: codeskyblue import pytest from uiautomator2 import Settings def test_settings(): settings = Settings(None) settings['wait_timeout'] = 10 assert settings['wait_timeout'] == 10 with pytest.raises(TypeError): settings['wait_timeout'] = '30' assert settings['wait_timeout'] == 10 with pytest.raises(AttributeError): settings['not_exists_key'] = 1 ================================================ FILE: tests/test_utils.py ================================================ # coding: utf-8 # import threading import time import pytest from PIL import Image from uiautomator2 import utils def test_list2cmdline(): testdata = [ [("echo", "hello"), "echo hello"], [("echo", "hello&world"), "echo 'hello&world'"], [("What's", "your", "name?"), """'What'"'"'s' your 'name?'"""], ["echo hello", "echo hello"], ] for args, expect in testdata: cmdline = utils.list2cmdline(args) assert cmdline == expect, "Args: %s, Expect: %s, Got: %s" % (args, expect, cmdline) def test_inject_call(): def foo(a, b, c=2): return a*100+b*10+c ret = utils.inject_call(foo, a=2, b=4) assert ret == 242 with pytest.raises(TypeError): utils.inject_call(foo, 2) def test_threadsafe_wrapper(): class A: n = 0 @utils.thread_safe_wrapper def call(self): v = self.n time.sleep(.5) self.n = v + 1 a = A() th1 = threading.Thread(name="th1", target=a.call) th2 = threading.Thread(name="th2", target=a.call) th1.start() th2.start() th1.join() th2.join() assert 2 == a.n def test_is_version_compatiable(): assert utils.is_version_compatiable("1.0.0", "1.0.0") assert utils.is_version_compatiable("1.0.0", "1.0.1") assert utils.is_version_compatiable("1.0.0", "1.2.0") assert utils.is_version_compatiable("1.0.1", "1.1.0") assert not utils.is_version_compatiable("1.0.1", "2.1.0") assert not utils.is_version_compatiable("1.3.1", "1.3.0") assert not utils.is_version_compatiable("1.3.1", "1.2.0") assert not utils.is_version_compatiable("1.3.1", "1.2.2") def test_naturalsize(): assert utils.natualsize(1) == "0.0 KB" assert utils.natualsize(1024) == "1.0 KB" assert utils.natualsize(1<<20) == "1.0 MB" assert utils.natualsize(1<<30) == "1.0 GB" def test_image_convert(): im = Image.new("RGB", (100, 100)) im2 = utils.image_convert(im, "pillow") assert isinstance(im2, Image.Image) with pytest.raises(ValueError): utils.image_convert(im, "unknown") def test_depreacated(): @utils.deprecated("use bar instead") def foo(): pass with pytest.warns(DeprecationWarning): foo() def test_with_package_resource(): with utils.with_package_resource("assets/sync.sh") as asset_path: assert asset_path.exists() assert asset_path.is_file() assert asset_path.name == "sync.sh" # Test that the context manager works properly with pytest.raises(FileNotFoundError): with utils.with_package_resource("nonexistent_file.xyz") as _: pass ================================================ FILE: tests/test_xpath.py ================================================ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """Created on Thu Apr 04 2024 16:41:25 by codeskyblue """ from unittest.mock import Mock import pytest from PIL import Image from uiautomator2.xpath import XMLElement, XPath, XPathElementNotFoundError, XPathEntry, XPathSelector, \ convert_to_camel_case, is_xpath_syntax_ok, safe_xmlstr, str2bytes, strict_xpath mock = Mock() mock.screenshot.return_value = Image.new("RGB", (1080, 1920), "white") mock.dump_hierarchy.return_value = """ """ x = XPathEntry(mock) def test_safe_xmlstr(): for input, expect in [ ('android.widget.TextView', 'android.widget.TextView'), ('test$123', 'test.123'), ('$@#&123.456$', '123.456'), ]: assert safe_xmlstr(input) == expect def test_str2bytes(): assert str2bytes(b'123') == b'123' assert str2bytes('123') == b'123' def test_is_xpath_syntax_ok(): assert is_xpath_syntax_ok("/a") assert is_xpath_syntax_ok("//a") assert is_xpath_syntax_ok("//a[@text='b]") is False assert is_xpath_syntax_ok("//a[") is False def test_convert_to_camel_case(): assert convert_to_camel_case("hello-world") == "helloWorld" def test_strict_xpath(): for (input, expect) in [ ("@n1", "//*[@resource-id='n1']"), ("//TextView", "//TextView"), ("//TextView[@text='n1']", "//TextView[@text='n1']"), ("(//TextView)[2]", "(//TextView)[2]"), ("//TextView/", "//TextView"), # test rstrip / ]: assert strict_xpath(input) == expect def test_XPath(): xp = XPath("//TextView") assert xp == "//TextView" assert xp.joinpath("/n1") == "//TextView/n1" def test_xpath_selector(): assert isinstance(x("n1"), XPathSelector) assert isinstance(x("//TextView"), XPathSelector) xp1 = x("n1") xp2 = xp1.child("n2") # xp1 should not be changed assert xp1.get(timeout=0).text == "n1" assert xp1.get_text() == "n1" # match return None or XMLElement assert xp1.match() is not None assert xp2.match() is None def test_xpath_with_instance(): # issue: https://github.com/openatx/uiautomator2/issues/941 el = x('(//TextView)[2]').get(0) assert el.text == "n2" def test_xpath_click(): x("n1").click() assert mock.click.called assert mock.click.call_args[0] == (540, 50) mock.click.reset_mock() assert x("n1").click_exists() == True assert mock.click.call_args[0] == (540, 50) mock.click.reset_mock() assert x("n3").click_exists(timeout=.1) == False assert not mock.click.called def test_xpath_exists(): assert x("n1").exists assert not x("n3").exists def test_xpath_wait_and_wait_gone(): assert x("n1").wait() is True assert x("n3").wait(timeout=.1) is False assert x("n3").wait_gone(timeout=.1) is True assert x("n1").wait_gone(timeout=.1) is False def test_xpath_get(): assert x("n1").get().text == "n1" assert x("n2").get().text == "n2" with pytest.raises(XPathElementNotFoundError): x("n3").get(timeout=.1) def test_xpath_all(): assert len(x("//TextView").all()) == 2 assert len(x("n3").all()) == 0 assert len(x("n1").all()) == 1 el = x("n1").all()[0] assert isinstance(el, XMLElement) assert el.text == "n1" def test_xpath_element(): el = x("n1").get(timeout=0) assert el.text == "n1" assert el.center() == (540, 50) assert el.offset(0, 0) == (0, 0) assert el.offset(1, 1) == (1080, 100) assert el.screenshot().size == (1080, 100) assert el.bounds == (0, 0, 1080, 100) assert el.rect == (0, 0, 1080, 100) assert isinstance(el.info, dict) assert el.get_xpath(strip_index=True) == "/hierarchy/FrameLayout/TextView" mock.click.reset_mock() el.click() assert mock.click.called assert mock.click.call_args[0] == (540, 50) mock.long_click.reset_mock() el.long_click() assert mock.long_click.called assert mock.long_click.call_args[0] == (540, 50) mock.swipe.reset_mock() el.swipe("up") assert mock.swipe.called ================================================ FILE: uiautomator2/__init__.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- from __future__ import absolute_import, print_function import base64 import contextlib import dataclasses import io import logging import os import re import time import warnings from functools import cached_property from typing import Any, Dict, List, Optional, Tuple, Union import adbutils from lxml import etree from PIL import Image from retry import retry from uiautomator2 import xpath from uiautomator2._input import InputMethodMixIn from uiautomator2._proto import HTTP_TIMEOUT, SCROLL_STEPS, Direction from uiautomator2._selector import Selector, UiObject from uiautomator2.abstract import AbstractShell, AbstractUiautomatorServer, ShellResponse from uiautomator2.base import _BaseClient from uiautomator2.exceptions import * from uiautomator2.settings import Settings from uiautomator2.swipe import SwipeExt from uiautomator2.utils import deprecated, image_convert, list2cmdline from uiautomator2.watcher import WatchContext, Watcher WAIT_FOR_DEVICE_TIMEOUT = int(os.getenv("WAIT_FOR_DEVICE_TIMEOUT", 20)) logger = logging.getLogger(__name__) def enable_pretty_logging(level=logging.DEBUG): if not logger.handlers: # pragma: no cover # Configure handler handler = logging.StreamHandler() formatter = logging.Formatter('[%(levelname)1.1s %(asctime)s %(module)s:%(lineno)d pid:%(process)d] %(message)s') handler.setFormatter(formatter) logger.addHandler(handler) logger.setLevel(level) class _Device(_BaseClient): __orientation = ( # device orientation (0, "natural", "n", 0), (1, "left", "l", 90), (2, "upsidedown", "u", 180), (3, "right", "r", 270)) def show_touch_trace(self, pointer_location: bool = True, show_touches: bool = True): """ Show touch trace on device screen Args: pointer_location (bool): screen overlay showing current touch data show_touches (bool): show visual feedback for taps """ self.shell(f"settings put system pointer_location {int(pointer_location)}") self.shell(f"settings put system show_touches {int(show_touches)}") def window_size(self): """ return (width, height) """ w, h = self._dev.window_size() return w, h def screenshot(self, filename: Optional[str] = None, format="pillow", display_id: Optional[int] = None): """ Take screenshot of device Returns: PIL.Image.Image, np.ndarray (OpenCV format) or None Args: filename (str): saved filename, if filename is set then return None format (str): used when filename is empty. one of ["pillow", "opencv"] display_id (int): use specific display if device has multiple screen Examples: screenshot("saved.jpg") screenshot().save("saved.png") cv2.imwrite('saved.jpg', screenshot(format='opencv')) """ if display_id is None: base64_data = self.jsonrpc.takeScreenshot(1, 80) # takeScreenshot may return None if base64_data: jpg_raw = base64.b64decode(base64_data) pil_img = Image.open(io.BytesIO(jpg_raw)) else: pil_img = self._dev.screenshot(display_id=0) else: pil_img = self._dev.screenshot(display_id=display_id) if filename: pil_img.save(filename) return return image_convert(pil_img, format) def dump_hierarchy(self, compressed=False, pretty=False, max_depth: Optional[int] = None) -> str: """ Dump window hierarchy Args: compressed (bool): return compressed xml pretty (bool): pretty print xml max_depth (int): max depth of hierarchy Returns: xml content """ try: if max_depth is None: max_depth = self.settings['max_depth'] content = self._do_dump_hierarchy(compressed, max_depth) except HierarchyEmptyError: # pragma: no cover logger.warning("dump empty, return empty xml") content = '\r\n' if pretty: root = etree.fromstring(content.encode("utf-8")) content = etree.tostring(root, pretty_print=True, encoding='UTF-8', xml_declaration=True) content = content.decode("utf-8") return content @retry(HierarchyEmptyError, tries=3, delay=1) def _do_dump_hierarchy(self, compressed=False, max_depth=None) -> str: if max_depth is None: max_depth = 50 content = self.jsonrpc.dumpWindowHierarchy(compressed, max_depth) if content == "": raise HierarchyEmptyError("dump hierarchy is empty") # '\r\n' if '' in content: logger.debug("dump empty, call clear_traversed_text and retry") # self.clear_traversed_text() raise HierarchyEmptyError("dump hierarchy is empty with no children") return content def implicitly_wait(self, seconds: Optional[float] = None) -> float: """set default wait timeout Args: seconds(float): to wait element show up Returns: Current implicitly wait seconds Deprecated: recommend use: d.settings['wait_timeout'] = 10 """ if seconds: self.settings["wait_timeout"] = seconds return self.settings['wait_timeout'] @property def pos_rel2abs(self): """ returns a function which can convert percent size to pixel size """ size = [] def _convert(x, y): assert x >= 0 assert y >= 0 if (x < 1 or y < 1) and not size: size.extend( self.window_size()) # size will be [width, height] if x < 1: x = int(size[0] * x) if y < 1: y = int(size[1] * y) return x, y return _convert @contextlib.contextmanager def _operation_delay(self, operation_name: str = None): before, after = self.settings['operation_delay'] # 排除不要求延迟的方法 if operation_name not in self.settings['operation_delay_methods']: before, after = 0, 0 if before: logger.debug(f"operation [{operation_name}] pre-delay {before}s") time.sleep(before) yield if after: logger.debug(f"operation [{operation_name}] post-delay {after}s") time.sleep(after) @property def touch(self): """ ACTION_DOWN: 0 ACTION_MOVE: 2 touch.down(x, y) touch.move(x, y) touch.up(x, y) """ ACTION_DOWN = 0 ACTION_MOVE = 2 ACTION_UP = 1 obj: "Device" = self class _Touch(object): def down(self, x, y): x, y = obj.pos_rel2abs(x, y) obj.jsonrpc.injectInputEvent(ACTION_DOWN, x, y, 0) return self def move(self, x, y): x, y = obj.pos_rel2abs(x, y) obj.jsonrpc.injectInputEvent(ACTION_MOVE, x, y, 0) return self def up(self, x, y): """ ACTION_UP x, y """ x, y = obj.pos_rel2abs(x, y) obj.jsonrpc.injectInputEvent(ACTION_UP, x, y, 0) return self def sleep(self, seconds: float): time.sleep(seconds) return self return _Touch() def click(self, x: Union[float, int], y: Union[float, int]): x, y = self.pos_rel2abs(x, y) with self._operation_delay("click"): self.jsonrpc.click(x, y) def double_click(self, x, y, duration=0.1): """ double click position """ x, y = self.pos_rel2abs(x, y) self.touch.down(x, y).up(x, y) time.sleep(duration) self.click(x, y) # use click last is for htmlreport def long_click(self, x, y, duration: float = .5): '''long click at arbitrary coordinates. Args: duration (float): seconds of pressed ''' x, y = self.pos_rel2abs(x, y) with self._operation_delay("click"): self.jsonrpc.click(x, y, int(duration*1000)) def swipe(self, fx, fy, tx, ty, duration: Optional[float] = None, steps: Optional[int] = None): """ Args: fx, fy: from position tx, ty: to position duration (float): duration steps: 1 steps is about 5ms, if set, duration will be ignore Documents: uiautomator use steps instead of duration As the document say: Each step execution is throttled to 5ms per step. Links: https://developer.android.com/reference/android/support/test/uiautomator/UiDevice.html#swipe%28int,%20int,%20int,%20int,%20int%29 """ if duration is not None and steps is not None: warnings.warn("duration and steps can not be set at the same time, use steps", UserWarning) duration = None if duration: steps = int(duration * 200) if not steps: steps = SCROLL_STEPS logger.debug("swipe from (%s, %s) to (%s, %s), steps: %d", fx, fy, tx, ty, steps) rel2abs = self.pos_rel2abs fx, fy = rel2abs(fx, fy) tx, ty = rel2abs(tx, ty) steps = max(2, steps) # step=1 has no swipe effect with self._operation_delay("swipe"): return self.jsonrpc.swipe(fx, fy, tx, ty, steps) def swipe_points(self, points: List[Tuple[int, int]], duration: float = 0.5): """ Args: points: is point array containg at least one point object. eg [[200, 300], [210, 320]] duration: duration to inject between two points Links: https://developer.android.com/reference/android/support/test/uiautomator/UiDevice.html#swipe(android.graphics.Point[], int) """ ppoints = [] rel2abs = self.pos_rel2abs for p in points: x, y = rel2abs(p[0], p[1]) ppoints.append(x) ppoints.append(y) # Each step execution is throttled to 5ms per step. So for a 100 steps, the swipe will take about 1/ 2 second to complete steps = int(duration / .005) return self.jsonrpc.swipePoints(ppoints, steps) def drag(self, sx, sy, ex, ey, duration=0.5): '''Swipe from one point to another point.''' rel2abs = self.pos_rel2abs sx, sy = rel2abs(sx, sy) ex, ey = rel2abs(ex, ey) with self._operation_delay("drag"): return self.jsonrpc.drag(sx, sy, ex, ey, int(duration * 200)) def press(self, key: Union[int, str], meta=None): """ press key via name or key code. Supported key name includes: home, back, left, right, up, down, center, menu, search, enter, delete(or del), recent(recent apps), volume_up, volume_down, volume_mute, camera, power. """ with self._operation_delay("press"): if isinstance(key, int): return self.jsonrpc.pressKeyCode( key, meta) if meta else self.jsonrpc.pressKeyCode(key) else: return self.jsonrpc.pressKey(key) def long_press(self, key: Union[int, str]): """ long press key via name or key code Args: key: key name or key code Examples: long_press("home") same as "adb shell input keyevent --longpress KEYCODE_HOME" """ with self._operation_delay("press"): if isinstance(key, int): self.shell("input keyevent --longpress %d" % key) else: key = key.upper() self.shell(f"input keyevent --longpress {key}") def screen_on(self): self.jsonrpc.wakeUp() def screen_off(self): self.jsonrpc.sleep() @property def orientation(self) -> str: ''' orienting the device to left/right or natural. left/l: rotation=90 , displayRotation=1 right/r: rotation=270, displayRotation=3 natural/n: rotation=0 , displayRotation=0 upsidedown/u: rotation=180, displayRotation=2 ''' return self.__orientation[self.info["displayRotation"]][1] @orientation.setter def orientation(self, value: str): '''setter of orientation property.''' for values in self.__orientation: if value in values: # can not set upside-down until api level 18. self.jsonrpc.setOrientation(values[1]) break else: raise ValueError("Invalid orientation.") def freeze_rotation(self, freezed: bool = True): self.jsonrpc.freezeRotation(freezed) @property def last_traversed_text(self): '''get last traversed text. used in webview for highlighted text.''' return self.jsonrpc.getLastTraversedText() def clear_traversed_text(self): '''clear the last traversed text.''' self.jsonrpc.clearLastTraversedText() @property def last_toast(self) -> Optional[str]: return self.jsonrpc.getLastToast() def clear_toast(self): self.jsonrpc.clearLastToast() def open_notification(self): return self.jsonrpc.openNotification() def open_quick_settings(self): return self.jsonrpc.openQuickSettings() def open_url(self, url: str): self.shell( ['am', 'start', '-a', 'android.intent.action.VIEW', '-d', url]) def exists(self, **kwargs): return self(**kwargs).exists @property def clipboard(self) -> Optional[str]: return self.jsonrpc.getClipboard() @clipboard.setter def clipboard(self, text: str): self.set_clipboard(text) def set_clipboard(self, text, label=None): ''' Args: text: The actual text in the clip. label: User-visible label for the clip data. ''' self.jsonrpc.setClipboard(label, text) def clear_text(self): """ clear input text """ self.jsonrpc.clearInputText() def send_keys(self, text: str): """ send text to focused input area Args: text: input text clear: clear text before input """ # 使用el =self(focused=True); el.set_text(el.get_text()+text)不可取 # 因为placeholder中的文字也会加进去 self.clipboard = text if self.clipboard != text: raise UiAutomationError("setClipboard failed") self.jsonrpc.pasteClipboard() def keyevent(self, v): """ Args: v: eg home wakeup back """ v = v.upper() self.shell("input keyevent " + v) @cached_property def serial(self) -> str: """ If connected with USB, here should return self._serial When this situation happends d = u2.connect_usb("10.0.0.1:5555") d.serial # should be "10.0.0.1:5555" d.shell(['getprop', 'ro.serialno']).output.strip() # should uniq str like ffee123ca This logic should not change, because it used in tmq-service and if you break it, some people will not happy """ if self._serial: return self._serial return self.shell(['getprop', 'ro.serialno']).output.strip() def __call__(self, **kwargs) -> 'UiObject': return UiObject(self, Selector(**kwargs)) class _AppMixIn(AbstractShell): def session(self, package_name: str, attach: bool = False) -> "Session": """ launch app and keep watching the app's state Args: package_name: package name attach: attach to existing session or not Returns: Session """ self.app_start(package_name, stop=not attach) return Session(self.adb_device, package_name) def _compat_shell_ps(self) -> str: """ Compatible with some devices that does not support `ps` command """ output = self.shell("ps -A").output if len(output.strip().splitlines()) <= 1: output = self.shell("ps").output return output.strip().replace("\r\n", "\n") def _pidof_app(self, package_name) -> Optional[int]: """ Return pid of package name """ output = self._compat_shell_ps() lines = output.splitlines() for line in lines: # line example: u0_a1 1318 123 1010000 27580 SyS_epoll_ 0000000000 S com.github.uiautomator fields = line.strip().split() if len(fields) < 9: continue if fields[-1] == package_name: return int(fields[1]) def app_current(self): """ Returns: dict(package, activity, pid?) Raises: DeviceError For developer: Function reset_uiautomator need this function, so can't use jsonrpc here. """ info = self.adb_device.app_current() if info: return dataclasses.asdict(info) raise DeviceError("Couldn't get focused app") def app_install(self, data: str): """ Install app Args: data: can be file path or url or file object """ self.adb_device.install(data) def wait_activity(self, activity, timeout=10) -> bool: """ wait activity Args: activity (str): name of activity timeout (float): max wait time Returns: bool of activity """ deadline = time.time() + timeout while time.time() < deadline: current_activity = self.app_current().get('activity') if activity == current_activity: return True time.sleep(.5) return False def app_start(self, package_name: str, activity: Optional[str] = None, wait: bool = False, stop: bool = False, use_monkey: bool = False): """ Launch application Args: package_name (str): package name activity (str): app activity stop (bool): Stop app before starting the activity. (require activity) use_monkey (bool): use monkey command to start app when activity is not given wait (bool): wait until app started. default False """ if stop: self.app_stop(package_name) if use_monkey or not activity: self.shell([ 'monkey', '-p', package_name, '-c', 'android.intent.category.LAUNCHER', '1' ]) if wait: self.app_wait(package_name) return # if not activity: # info = self.app_info(package_name) # activity = info['mainActivity'] # if activity.find(".") == -1: # activity = "." + activity # -D: enable debugging # -W: wait for launch to complete # -S: force stop the target app before starting the activity # --user | current: Specify which user to run as; if not # specified then run as the current user. # -e # --ei # --ez args = [ 'am', 'start', '-a', 'android.intent.action.MAIN', '-c', 'android.intent.category.LAUNCHER', '-n', f'{package_name}/{activity}' ] self.shell(args) if wait: self.app_wait(package_name) def app_wait(self, package_name: str, timeout: float = 20.0, front=False) -> int: """ Wait until app launched Args: package_name (str): package name timeout (float): maxium wait time front (bool): wait until app is current app Returns: pid (int) 0 if launch failed """ pid = None deadline = time.time() + timeout while time.time() < deadline: if front: if self.app_current()['package'] == package_name: pid = self._pidof_app(package_name) else: if package_name in self.app_list_running(): pid = self._pidof_app(package_name) if pid: return pid time.sleep(1) return pid or 0 def app_list(self, filter: str = None) -> List[str]: """ List installed app package names Args: filter: [-f] [-d] [-e] [-s] [-3] [-i] [-u] [--user USER_ID] [FILTER] Returns: list of apps by filter """ output, _ = self.shell(['pm', 'list', 'packages', filter]) packages = re.findall(r'package:([^\s]+)', output) return list(packages) def app_list_running(self) -> List[str]: """ Returns: list of running apps """ output, _ = self.shell('pm list packages') packages = re.findall(r'package:([^\s]+)', output) ps_output = self._compat_shell_ps() process_names = re.findall(r'(\S+)$', ps_output, re.M) return list(set(packages).intersection(process_names)) def app_stop(self, package_name: str): """ Stop one application """ self.adb_device.app_stop(package_name) def app_stop_all(self, excludes=[]): """ Stop all third party applications Args: excludes (list): apps that do now want to kill Returns: a list of killed apps """ our_apps = ['com.github.uiautomator', 'com.github.uiautomator.test'] kill_pkgs = set(self.app_list_running()).difference(our_apps + excludes) for pkg_name in kill_pkgs: self.app_stop(pkg_name) return list(kill_pkgs) def app_clear(self, package_name: str): """ Stop and clear app data: pm clear """ self.adb_device.app_clear(package_name) def app_uninstall(self, package_name: str) -> bool: """ Uninstall an app Returns: bool: success """ ret = self.shell(["pm", "uninstall", package_name]) return ret.exit_code == 0 def app_uninstall_all(self, excludes=[], verbose=False): """ Uninstall all apps """ our_apps = ['com.github.uiautomator', 'com.github.uiautomator.test'] output, _ = self.shell(['pm', 'list', 'packages', '-3']) pkgs = re.findall(r'package:([^\s]+)', output) pkgs = set(pkgs).difference(our_apps + excludes) pkgs = list(pkgs) for pkg_name in pkgs: if verbose: print("uninstalling", pkg_name, " ", end="", flush=True) ok = self.app_uninstall(pkg_name) if verbose: print("OK" if ok else "FAIL") return pkgs def app_info(self, package_name: str) -> Dict[str, Any]: """ Get app info Args: package_name (str): package name Return example: { "versionName": "1.1.7", "versionCode": 1001007 } Raises: AppNotFoundError """ info = self.adb_device.app_info(package_name) if not info: raise AppNotFoundError("App not installed", package_name) return { "versionName": info.version_name, "versionCode": info.version_code, } def app_auto_grant_permissions(self, package_name: str): """ auto grant permissions Args: package_name (str): package name Help of "adb shell pm": grant [--user USER_ID] PACKAGE PERMISSION revoke [--user USER_ID] PACKAGE PERMISSION These commands either grant or revoke permissions to apps. The permissions must be declared as used in the app's manifest, be runtime permissions (protection level dangerous), and the app targeting SDK greater than Lollipop MR1 (API level 22). Help of "Android official pm" see Grant a permission to an app. On devices running Android 6.0 (API level 23) and higher, the permission can be any permission declared in the app manifest. On devices running Android 5.1 (API level 22) and lower, must be an optional permission defined by the app. """ sdk_version_output = self.shell(['getprop', 'ro.build.version.sdk']).output.strip() sdk_version = int(sdk_version_output) if sdk_version_output.isdigit() else None if sdk_version is None: logger.warning("can't get sdk version") return if sdk_version < 23: # TODO: support android 5.1 (API 22) and lower logger.warning("auto grant permissions only support android 6.0+ (API 23+)") return dumpsys_package_output = self.shell(['dumpsys', 'package', package_name]).output target_sdk_match = re.search(r'targetSdk=(\d+)', dumpsys_package_output) if not target_sdk_match: logger.warning("can't get targetSdk from dumpsys package") return target_sdk = int(target_sdk_match.group(1)) if target_sdk < 22: logger.warning("auto grant permissions only support app targetSdk >= 22") return permissions = re.findall(r'(android\.\w*\.?permission\.\w+): granted=false', dumpsys_package_output) for permission in permissions: self.shell(['pm', 'grant', package_name, permission]) logger.info(f'auto grant permission {permission}') class _DeprecatedMixIn: # pragma: no cover @property def wait_timeout(self): # wait element timeout return self.settings['wait_timeout'] @wait_timeout.setter def wait_timeout(self, v: Union[int, float]): self.settings['wait_timeout'] = v @property def click_post_delay(self): """ Deprecated or not deprecated, this is a question """ return self.settings['post_delay'] @click_post_delay.setter def click_post_delay(self, v: Union[int, float]): self.settings['post_delay'] = v def unlock(self): """ unlock screen with swipe from left-bottom to right-top """ if not self.info['screenOn']: # WAKEUP might be stuck self.shell("input keyevent POWER") self.swipe(0.1, 0.9, 0.9, 0.1) def show_float_window(self, show=True): """ 显示悬浮窗,提高uiautomator运行的稳定性 """ print("show_float_window is deprecated, this is not needed anymore") @deprecated(reason="use d.toast.show(text, duration) instead") def make_toast(self, text, duration=1.0): """ Show toast Args: text (str): text to show duration (float): seconds of display """ return self.jsonrpc.makeToast(text, duration * 1000) @property def toast(self): obj = self class Toast(object): def get_message(self, wait_timeout=10, cache_timeout=10, default=None): """ Args: wait_timeout: seconds of max wait time if toast now show right now cache_timeout: depreacated default: default messsage to return when no toast show up Returns: None or toast message """ deadline = time.time() + wait_timeout while 1: message = obj.jsonrpc.getLastToast() if message: return message if time.time() > deadline: return default time.sleep(.5) def reset(self): return obj.jsonrpc.clearLastToast() def show(self, text, duration=1.0): return obj.jsonrpc.makeToast(text, duration * 1000) return Toast() def set_orientation(self, value: str): '''setter of orientation property.''' self.orientation = value class _PluginMixIn: def watch_context(self, autostart: bool = True, builtin: bool = False) -> WatchContext: wc = WatchContext(self, builtin=builtin) if autostart: wc.start() return wc @cached_property def watcher(self) -> Watcher: return Watcher(self) @cached_property def xpath(self) -> xpath.XPathEntry: return xpath.XPathEntry(self) @cached_property def image(self): from uiautomator2 import image as _image return _image.ImageX(self) @cached_property def screenrecord(self): from uiautomator2 import screenrecord as _sr return _sr.Screenrecord(self) @cached_property def swipe_ext(self) -> SwipeExt: return SwipeExt(self) class Device(_Device, _AppMixIn, _PluginMixIn, InputMethodMixIn, _DeprecatedMixIn): """ Device object """ def clear_text(self): """ clear input text """ if self.is_input_ime_installed(): InputMethodMixIn.clear_text(self) else: _Device.clear_text(self) def send_keys(self, text: str, clear: bool = False): """ send text to focused input area Args: text: input text clear: clear text before input """ if clear: self.clear_text() if self.is_input_ime_installed(): InputMethodMixIn.send_keys(self, text) return try: _Device.send_keys(self, text) except: # 安装输入法后继续输入 InputMethodMixIn.send_keys(self, text) class Session(Device): """Session keeps watch the app status each jsonrpc call will check if the package is still running """ def __init__(self, dev: adbutils.AdbDevice, package_name: str): super().__init__(dev) self._package_name = package_name self._pid = self.app_wait(self._package_name) def running(self) -> bool: return self._pid == self._pidof_app(self._package_name) @property def pid(self) -> int: return self._pid def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any: if not self.running(): raise SessionBrokenError(f"app:{self._package_name} pid:{self._pid} is quit") return super().jsonrpc_call(method, params, timeout) def restart(self): """ restart app """ self.app_start(self._package_name, wait=True, stop=True) self._pid = self._pidof_app(self._package_name) def close(self): """ close app """ self.app_stop(self._package_name) self._pid = None def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self.close() def connect(serial: Union[str, adbutils.AdbDevice] = None) -> Device: """ Args: serial (str): Android device serialno Returns: Device Raises: ConnectError Example: connect("10.0.0.1:5555") connect("cff1123ea") # adb device serial number """ if not serial: serial = os.getenv("ANDROID_SERIAL") return connect_usb(serial) def connect_usb(serial: Optional[str] = None) -> Device: """ Args: serial (str): android device serial Returns: Device Raises: ConnectError """ if not serial: serial = adbutils.adb.device() return Device(serial) ================================================ FILE: uiautomator2/__main__.py ================================================ # coding: utf-8 # from __future__ import absolute_import, print_function import argparse import json import logging import pathlib import shutil import sys import adbutils import uiautomator2 as u2 from uiautomator2 import enable_pretty_logging from uiautomator2.utils import with_package_resource from uiautomator2.version import __version__ logger = logging.getLogger(__name__) def cmd_init(args): serial = args.serial or args.serial_optional if serial: d = u2.connect(serial) logger.debug("install apk to %s", d.serial) d._setup_jar() else: for dev in adbutils.adb.iter_device(): d = u2.connect(dev) logger.debug("install apk to %s", d.serial) d._setup_jar() d._setup_ime() def cmd_purge(args): """remove minicap, minitouch, uiautomator ...""" dev = adbutils.adb.device(args.serial) dev.uninstall("com.github.uiautomator") dev.uninstall("com.github.uiautomator.test") dev.shell(["/data/local/tmp/atx-agent", "server", "--stop"]) dev.shell(["rm", "/data/local/tmp/atx-agent"]) logger.info("atx-agent stopped and removed") dev.shell(["rm", "/data/local/tmp/minicap"]) dev.shell(["rm", "/data/local/tmp/minicap.so"]) dev.shell(["rm", "/data/local/tmp/minitouch"]) logger.info("minicap, minitouch removed") dev.shell(["pm", "uninstall", "com.github.uiautomator"]) dev.shell(["pm", "uninstall", "com.github.uiautomator.test"]) logger.info("com.github.uiautomator uninstalled, all done !!!") def cmd_copy_assets(args): target_dir = pathlib.Path("assets") target_dir.mkdir(exist_ok=True) with with_package_resource("assets/u2.jar") as jar_path: target_path = target_dir / "u2.jar" shutil.copy2(jar_path, target_path) print("Copied u2.jar to", target_path) with with_package_resource("assets/app-uiautomator.apk") as apk_path: target_path = target_dir / "app-uiautomator.apk" shutil.copy2(apk_path, target_path) print("Copied app-uiautomator.apk to", target_path) def cmd_screenshot(args): d = u2.connect(args.serial) d.screenshot().save(args.filename) print("Save screenshot to %s" % args.filename) def cmd_install(args): u = u2.connect(args.serial) pkg_name = u.app_install(args.url) print("Installed", pkg_name) def cmd_uninstall(args): d = u2.connect(args.serial) if args.all: d.app_uninstall_all(verbose=True) else: for package_name in args.package_name: print('Uninstall "%s" ' % package_name, end="", flush=True) ok = d.app_uninstall(package_name) print("OK" if ok else "FAIL") def cmd_start(args): d = u2.connect(args.serial) d.app_start(args.package_name) def cmd_stop(args): d = u2.connect(args.serial) if args.all: d.app_stop_all() return for package_name in args.package_name: print('am force-stop "%s" ' % package_name) d.app_stop(package_name) def cmd_current(args): d = u2.connect(args.serial) print(json.dumps(d.app_current(), indent=4), flush=True) def cmd_doctor(args): """check if environment is fine""" d = u2.connect(args.serial) logger.debug("device serial: %s", d.serial) try: d.info logger.info("uiautomator2 is OK") except Exception as e: logger.error("error: %s", e) sys.exit(1) def cmd_version(args): """print uiautomator2 lib version""" print("uiautomator2 version: %s" % __version__) def cmd_console(args): import code import platform d = u2.connect(args.serial) model = d.shell("getprop ro.product.model").output.strip() serial = d.serial try: import IPython from traitlets.config import get_config c = get_config() c.InteractiveShellEmbed.colors = "neutral" IPython.embed(config=c, header=f"IPython is ready, uiautomator2: {__version__}, try d.info") except ImportError: _vars = globals().copy() _vars.update(locals()) shell = code.InteractiveConsole(_vars) shell.interact( banner="Python: %s\nDevice: %s(%s)" % (platform.python_version(), model, serial) ) _commands = [ dict(action=cmd_version, command="version", help="show version"), dict( action=cmd_init, command="init", help="install enssential resources to device", flags=[ dict( args=["--addr"], default="127.0.0.1:7912", help="atx-agent listen address", ), dict(args=["--serial", "-s"], type=str, help="serial number"), dict( args=["serial_optional"], nargs="?", help="serial number, same as --serial", ), ], ), dict( action=cmd_copy_assets, command="copy-assets", help="copy uiautomator2 assets to current directory", ), dict( action=cmd_screenshot, command="screenshot", help="take device screenshot", flags=[ dict( args=["filename"], nargs="?", default="screenshot.jpg", type=str, help="output filename, jpg or png", ) ], ), dict( action=cmd_install, command="install", help="install packages", flags=[ dict(args=["url"], help="package url"), ], ), dict( action=cmd_uninstall, command="uninstall", help="uninstall packages", flags=[ dict(args=["--all"], action="store_true", help="uninstall all packages"), dict(args=["package_name"], nargs="*", help="package name"), ], ), dict( action=cmd_start, command="start", help="start application", flags=[dict(args=["package_name"], type=str, nargs=None, help="package name")], ), dict( action=cmd_stop, command="stop", help="stop application", flags=[ dict(args=["--all"], action="store_true", help="stop all"), dict(args=["package_name"], nargs="*", help="package name"), ], ), dict(action=cmd_current, command="current", help="show current application"), dict(action=cmd_doctor, command="doctor", help="detect connect problem"), dict( action=cmd_console, command="console", help="launch interactive python console" ), dict( action=cmd_purge, command="purge", help="remove minitouch, minicap, atx app etc, from device", ), ] def main(): # yapf: disable parser = argparse.ArgumentParser( formatter_class=argparse.ArgumentDefaultsHelpFormatter) parser.add_argument("-d", "--debug", action="store_true", help="show log") parser.add_argument('-s', '--serial', type=str, help='device serial number') subparser = parser.add_subparsers(dest='subparser') actions = {} for c in _commands: cmd_name = c['command'] actions[cmd_name] = c['action'] sp = subparser.add_parser(cmd_name, help=c.get('help'), formatter_class=argparse.ArgumentDefaultsHelpFormatter) for f in c.get('flags', []): args = f.get('args') if not args: args = ['-'*min(2, len(n)) + n for n in f['name']] kwargs = f.copy() kwargs.pop('name', None) kwargs.pop('args', None) sp.add_argument(*args, **kwargs) args = parser.parse_args() enable_pretty_logging() if args.debug: logger.debug("args: %s", args) if args.subparser: actions[args.subparser](args) return parser.print_help() # yapf: enable if __name__ == "__main__": main() ================================================ FILE: uiautomator2/_input.py ================================================ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """Created on Wed May 22 2024 16:23:56 by codeskyblue """ import base64 import logging import re import time import warnings from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Optional, Union import adbutils from retry import retry from uiautomator2.abstract import AbstractShell from uiautomator2.exceptions import AdbBroadcastError, DeviceError, InputIMEError from uiautomator2.utils import deprecated, with_package_resource logger = logging.getLogger(__name__) @dataclass class BroadcastResult: code: Optional[int] data: Optional[str] BORADCAST_RESULT_OK = -1 BROADCAST_RESULT_CANCELED = 0 class InputMethodMixIn(AbstractShell): # @property # def clipboard(self) -> Optional[str]: # result = self._broadcast("ADB_KEYBOARD_GET_CLIPBOARD") # if result.code == BORADCAST_RESULT_OK: # return base64.b64decode(result.data).decode('utf-8') # # jsonrpc.getClipboard is not OK for now # return None @property def __ime_id(self) -> str: return 'com.github.uiautomator/.AdbKeyboard' def set_input_ime(self, enable: bool = True): """ Enable of Disable InputIME """ if not enable: self.shell(['ime', 'disable', self.__ime_id]) return if self.current_ime() == self.__ime_id: return # prepare ime if self.__ime_id not in self.__get_ime_list(): self._setup_ime() assert self.__ime_id in self.__get_ime_list() self.shell(['ime', 'enable', self.__ime_id]) self.shell(['ime', 'set', self.__ime_id]) self.shell(['settings', 'put', 'secure', 'default_input_method', self.__ime_id]) self._wait_ime_ready() def is_input_ime_installed(self) -> bool: return self.__ime_id in self.__get_ime_list() def _setup_ime(self): logger.debug("installing AdbKeyboard ime") with with_package_resource("assets/app-uiautomator.apk") as ime_apk_path: try: self.adb_device.install(str(ime_apk_path), nolaunch=True, uninstall=True) except adbutils.AdbError as e: self.adb_device.uninstall(self.__ime_id.split('/')[0]) self.adb_device.install(str(ime_apk_path), nolaunch=True, uninstall=True) # wait for ime registered for _ in range(10): if self.__ime_id in self.__get_ime_list(): return time.sleep(.3) raise InputIMEError("install AdbKeyboard ime failed") def _broadcast(self, action: str, extras: Dict[str, str] = {}) -> BroadcastResult: # requires ATX 2.4.0+ args = ['am', 'broadcast', '-a', action] for k, v in extras.items(): if isinstance(v, int): args.extend(['--ei', k, str(v)]) else: args.extend(['--es', k, v]) # Example output: result=-1 data="success" output = self.shell(args).output m_result = re.search(r'result=(-?\d+)', output) m_data = re.search(r'data="([^"]+)"', output) result = int(m_result.group(1)) if m_result else None data = m_data.group(1) if m_data else None return BroadcastResult(result, data) @retry(AdbBroadcastError, tries=3, delay=1, jitter=0.5) def _must_broadcast(self, action: str, extras: Dict[str, str] = {}): result = self._broadcast(action, extras) if result.code != BORADCAST_RESULT_OK: raise AdbBroadcastError(f"broadcast {action} failed: {result.data}") def send_keys(self, text: str): try: self.set_input_ime() btext = text.encode('utf-8') base64text = base64.b64encode(btext).decode() cmd = "ADB_KEYBOARD_INPUT_TEXT" self._must_broadcast(cmd, {"text": base64text}) # Hide keyboard after successful input when using custom IME self._must_broadcast('ADB_KEYBOARD_HIDE') return True except AdbBroadcastError: warnings.warn( "set FastInputIME failed. use \"d(focused=True).set_text instead\"", Warning) return self(focused=True).set_text(text) def send_action(self, code: Union[str, int] = None): """ Simulate input method edito code Args: code (str or int): input method editor code Examples: send_action("search"), send_action(3) Refs: https://developer.android.com/reference/android/view/inputmethod/EditorInfo """ self.set_input_ime(True) __alias = { "go": 2, "search": 3, "send": 4, "next": 5, "done": 6, "previous": 7, } if isinstance(code, str): code = __alias.get(code, code) if code: self._must_broadcast('ADB_KEYBOARD_EDITOR_CODE', {"code": str(code)}) else: self._must_broadcast('ADB_KEYBOARD_SMART_ENTER') def clear_text(self): self.set_input_ime(True) self._must_broadcast('ADB_KEYBOARD_CLEAR_TEXT') def current_ime(self) -> str: """ Current input method Returns: ime_method Example output: "com.github.uiautomator/.FastInputIME" """ return self.shell(['settings', 'get', 'secure', 'default_input_method']).output.strip() # _INPUT_METHOD_RE = re.compile(r'mCurMethodId=([-_./\w]+)') # dim, _ = self.shell(['dumpsys', 'input_method']) # m = _INPUT_METHOD_RE.search(dim) # method_id = None if not m else m.group(1) # shown = "mInputShown=true" in dim # return (method_id, shown) def _wait_ime_ready(self, timeout: float = 5.0) -> bool: """ Wait for input method is ready """ deadline = time.time() + timeout while time.time() < deadline: if self.current_ime() == self.__ime_id: return True time.sleep(0.1) return False def __get_ime_list(self) -> List[str]: ret = self.shell(['ime', 'list', '-s', '-a']) return ret.output.strip().splitlines(keepends=False) def hide_keyboard(self): """ Hide keyboard """ self.set_input_ime() self._must_broadcast('ADB_KEYBOARD_HIDE') @deprecated(reason="use set_input_ime instead") def set_fastinput_ime(self, enable: bool = True): return self.set_input_ime(enable) @deprecated(reason="use set_input_ime instead") def wait_fastinput_ime(self, timeout=5.0): """ wait FastInputIME is ready (Depreacated in version 3.1) """ pass ================================================ FILE: uiautomator2/_proto.py ================================================ import enum SCROLL_STEPS = 55 HTTP_TIMEOUT = 300 class Direction(str, enum.Enum): LEFT = "left" RIGHT = "right" UP = "up" DOWN = "down" # 垂直操作 FORWARD = "up" BACKWARD = "down" # 水平操作 HORIZ_FORWARD = "left" HORIZ_BACKWARD = "right" ================================================ FILE: uiautomator2/_selector.py ================================================ import logging import time import warnings from typing import Optional, Tuple, List, Dict from PIL import Image from retry import retry from uiautomator2._proto import SCROLL_STEPS from uiautomator2.exceptions import HTTPError, UiObjectNotFoundError from uiautomator2.utils import Exists, intersect class Selector(dict): """The class is to build parameters for UiSelector passed to Android device. """ __fields = { "text": (0x01, None), # MASK_TEXT, "textContains": (0x02, None), # MASK_TEXTCONTAINS, "textMatches": (0x04, None), # MASK_TEXTMATCHES, "textStartsWith": (0x08, None), # MASK_TEXTSTARTSWITH, "className": (0x10, None), # MASK_CLASSNAME "classNameMatches": (0x20, None), # MASK_CLASSNAMEMATCHES "description": (0x40, None), # MASK_DESCRIPTION "descriptionContains": (0x80, None), # MASK_DESCRIPTIONCONTAINS "descriptionMatches": (0x0100, None), # MASK_DESCRIPTIONMATCHES "descriptionStartsWith": (0x0200, None), # MASK_DESCRIPTIONSTARTSWITH "checkable": (0x0400, False), # MASK_CHECKABLE "checked": (0x0800, False), # MASK_CHECKED "clickable": (0x1000, False), # MASK_CLICKABLE "longClickable": (0x2000, False), # MASK_LONGCLICKABLE, "scrollable": (0x4000, False), # MASK_SCROLLABLE, "enabled": (0x8000, False), # MASK_ENABLED, "focusable": (0x010000, False), # MASK_FOCUSABLE, "focused": (0x020000, False), # MASK_FOCUSED, "selected": (0x040000, False), # MASK_SELECTED, "packageName": (0x080000, None), # MASK_PACKAGENAME, "packageNameMatches": (0x100000, None), # MASK_PACKAGENAMEMATCHES, "resourceId": (0x200000, None), # MASK_RESOURCEID, "resourceIdMatches": (0x400000, None), # MASK_RESOURCEIDMATCHES, "index": (0x800000, 0), # MASK_INDEX, "instance": (0x01000000, 0) # MASK_INSTANCE, } __mask, __childOrSibling, __childOrSiblingSelector = "mask", "childOrSibling", "childOrSiblingSelector" def __init__(self, **kwargs): super(Selector, self).__setitem__(self.__mask, 0) super(Selector, self).__setitem__(self.__childOrSibling, []) super(Selector, self).__setitem__(self.__childOrSiblingSelector, []) for k in kwargs: self[k] = kwargs[k] def __str__(self): """ remove useless part for easily debugger """ selector = self.copy() selector.pop('mask') for key in ('childOrSibling', 'childOrSiblingSelector'): if not selector.get(key): selector.pop(key) args = [] for (k, v) in selector.items(): args.append(k + '=' + repr(v)) return 'Selector [' + ', '.join(args) + ']' def __setitem__(self, k, v): if k in self.__fields: super(Selector, self).__setitem__(k, v) super(Selector, self).__setitem__(self.__mask, self[self.__mask] | self.__fields[k][0]) else: raise ReferenceError("%s is not allowed." % k) def __delitem__(self, k): if k in self.__fields: super(Selector, self).__delitem__(k) super(Selector, self).__setitem__(self.__mask, self[self.__mask] & ~self.__fields[k][0]) def clone(self): kwargs = dict((k, self[k]) for k in self if k not in [ self.__mask, self.__childOrSibling, self.__childOrSiblingSelector ]) selector = Selector(**kwargs) for v in self[self.__childOrSibling]: selector[self.__childOrSibling].append(v) for s in self[self.__childOrSiblingSelector]: selector[self.__childOrSiblingSelector].append(s.clone()) return selector def child(self, **kwargs): self[self.__childOrSibling].append("child") self[self.__childOrSiblingSelector].append(Selector(**kwargs)) return self def sibling(self, **kwargs): self[self.__childOrSibling].append("sibling") self[self.__childOrSiblingSelector].append(Selector(**kwargs)) return self def update_instance(self, i): # update inside child instance if self[self.__childOrSiblingSelector]: self[self.__childOrSiblingSelector][-1]['instance'] = i else: self['instance'] = i class UiObject(object): def __init__(self, session, selector: Selector): self.session = session self.selector = selector self.jsonrpc = session.jsonrpc @property def wait_timeout(self): return self.session.wait_timeout @property def exists(self): '''check if the object exists in current window.''' return Exists(self) @property def info(self): '''ui object info.''' return self.jsonrpc.objInfo(self.selector) def info_list(self) -> List[Dict]: '''all matched ui objects info list.''' return self.jsonrpc.objInfoOfAllInstances(self.selector) def screenshot(self, display_id: Optional[int] = None) -> Image.Image: im = self.session.screenshot(display_id=display_id) return im.crop(self.bounds()) def click(self, timeout=None, offset=None): """ Click UI element. Args: timeout: seconds wait element show up offset: (xoff, yoff) default (0.5, 0.5) -> center The click method does the same logic as java uiautomator does. 1. waitForExists 2. get VisibleBounds center 3. send click event Raises: UiObjectNotFoundError """ # self.jsonrpc.click(self.selector) self.must_wait(timeout=timeout) x, y = self.center(offset=offset) self.session.click(x, y) def bounds(self) -> Tuple[int, int, int, int]: """ Returns: left_top_x, left_top_y, right_bottom_x, right_bottom_y """ info = self.info bounds = info.get('visibleBounds') or info.get("bounds") lx, ly, rx, ry = bounds['left'], bounds['top'], bounds['right'], bounds['bottom'] # yapf: disable return (lx, ly, rx, ry) def center(self, offset=(0.5, 0.5)): """ Args: offset: optional, (x_off, y_off) (0, 0) means left-top, (0.5, 0.5) means middle(Default) Return: center point (x, y) """ lx, ly, rx, ry = self.bounds() if offset is None: offset = (0.5, 0.5) # default center xoff, yoff = offset width, height = rx - lx, ry - ly x = lx + width * xoff y = ly + height * yoff return (x, y) def click_gone(self, maxretry=10, interval=1.0): """ Click until element is gone Args: maxretry (int): max click times interval (float): sleep time between clicks Return: Bool if element is gone """ self.click_exists() while maxretry > 0: time.sleep(interval) if not self.exists: return True self.click_exists() maxretry -= 1 return False def click_exists(self, timeout=0) -> bool: try: self.click(timeout=timeout) return True except UiObjectNotFoundError: return False def long_click(self, duration: float = 0.5, timeout=None): """ Args: duration (float): seconds of pressed timeout (float): seconds wait element show up """ # if info['longClickable'] and not duration: # return self.jsonrpc.longClick(self.selector) self.must_wait(timeout=timeout) x, y = self.center() return self.session.long_click(x, y, duration) def drag_to(self, *args, **kwargs): duration = kwargs.pop('duration', 0.5) timeout = kwargs.pop('timeout', None) self.must_wait(timeout=timeout) steps = int(duration * 200) if len(args) >= 2 or "x" in kwargs or "y" in kwargs: def drag2xy(x, y): x, y = self.session.pos_rel2abs(x, y) # convert percent position return self.jsonrpc.dragTo(self.selector, x, y, steps) return drag2xy(*args, **kwargs) return self.jsonrpc.dragTo(self.selector, Selector(**kwargs), steps) def swipe(self, direction, steps=10): """ Performs the swipe action on the UiObject. Swipe from center Args: direction (str): one of ("left", "right", "up", "down") steps (int): move steps, one step is about 5ms percent: float between [0, 1] Note: percent require API >= 18 # assert 0 <= percent <= 1 """ assert direction in ("left", "right", "up", "down") self.must_wait() info = self.info bounds = info.get('visibleBounds') or info.get("bounds") lx, ly, rx, ry = bounds['left'], bounds['top'], bounds['right'], bounds['bottom'] # yapf: disable cx, cy = (lx + rx) // 2, (ly + ry) // 2 if direction == 'up': self.session.swipe(cx, cy, cx, ly, steps=steps) elif direction == 'down': self.session.swipe(cx, cy, cx, ry - 1, steps=steps) elif direction == 'left': self.session.swipe(cx, cy, lx, cy, steps=steps) elif direction == 'right': self.session.swipe(cx, cy, rx - 1, cy, steps=steps) # return self.jsonrpc.swipe(self.selector, direction, percent, steps) def gesture(self, start1, start2, end1, end2, steps=100): ''' perform two point gesture. Usage: d().gesture(startPoint1, startPoint2, endPoint1, endPoint2, steps) ''' rel2abs = self.session.pos_rel2abs def point(x=0, y=0): x, y = rel2abs(x, y) return {"x": x, "y": y} def ctp(pt): return point(*pt) if type(pt) == tuple else pt s1, s2, e1, e2 = ctp(start1), ctp(start2), ctp(end1), ctp(end2) return self.jsonrpc.gesture(self.selector, s1, s2, e1, e2, steps) def pinch_in(self, percent=100, steps=50): return self.jsonrpc.pinchIn(self.selector, percent, steps) def pinch_out(self, percent=100, steps=50): return self.jsonrpc.pinchOut(self.selector, percent, steps) def wait(self, exists=True, timeout=None): """ Wait until UI Element exists or gone Args: timeout (float): wait element timeout Example: d(text="Clock").wait() d(text="Settings").wait(exists=False) # wait until it's gone """ if timeout is None: timeout = self.wait_timeout http_wait = timeout + 10 if exists: try: return self.jsonrpc.waitForExists(self.selector, int(timeout * 1000), http_timeout=http_wait) except HTTPError as e: warnings.warn("waitForExists readTimeout: %s" % e, RuntimeWarning) return self.exists() else: try: return self.jsonrpc.waitUntilGone(self.selector, int(timeout * 1000), http_timeout=http_wait) except HTTPError as e: warnings.warn("waitForExists readTimeout: %s" % e, RuntimeWarning) return not self.exists() def wait_gone(self, timeout=None): """ wait until ui gone Args: timeout (float): wait element gone timeout Returns: bool if element gone """ timeout = timeout or self.wait_timeout return self.wait(exists=False, timeout=timeout) def must_wait(self, exists=True, timeout=None): """ wait and if not found raise UiObjectNotFoundError """ if not self.wait(exists, timeout): raise UiObjectNotFoundError({'code': -32002, 'data': str(self.selector), 'method': 'wait'}) def send_keys(self, text): """ alias of set_text """ return self.set_text(text) def set_text(self, text, timeout=None): self.must_wait(timeout=timeout) if not text: return self.jsonrpc.clearTextField(self.selector) else: return self.jsonrpc.setText(self.selector, text) def get_text(self, timeout=None): """ get text from field """ self.must_wait(timeout=timeout) return self.jsonrpc.getText(self.selector) def clear_text(self, timeout=None): self.must_wait(timeout=timeout) return self.set_text(None) def child(self, **kwargs): return UiObject(self.session, self.selector.clone().child(**kwargs)) def sibling(self, **kwargs): return UiObject(self.session, self.selector.clone().sibling(**kwargs)) child_selector, from_parent = child, sibling def child_by_text(self, txt, **kwargs): if "allow_scroll_search" in kwargs: allow_scroll_search = kwargs.pop("allow_scroll_search") name = self.jsonrpc.childByText(self.selector, Selector(**kwargs), txt, allow_scroll_search) else: name = self.jsonrpc.childByText(self.selector, Selector(**kwargs), txt) return UiObject(self.session, name) def child_by_description(self, txt, **kwargs): # need test if "allow_scroll_search" in kwargs: allow_scroll_search = kwargs.pop("allow_scroll_search") name = self.jsonrpc.childByDescription(self.selector, Selector(**kwargs), txt, allow_scroll_search) else: name = self.jsonrpc.childByDescription(self.selector, Selector(**kwargs), txt) return UiObject(self.session, name) def child_by_instance(self, inst, **kwargs): # need test return UiObject( self.session, self.jsonrpc.childByInstance(self.selector, Selector(**kwargs), inst)) def parent(self): # android-uiautomator-server not implemented # In UIAutomator, UIObject2 has getParent() method # https://developer.android.com/reference/android/support/test/uiautomator/UiObject2.html raise NotImplementedError() # return UiObject(self.session, self.jsonrpc.getParent(self.selector)) def __getitem__(self, instance: int): """ Raises: IndexError """ if isinstance(self.selector, str): raise IndexError( "Index is not supported when UiObject returned by child_by_xxx" ) selector = self.selector.clone() if instance < 0: selector['instance'] = 0 del selector['instance'] count = self.jsonrpc.count(selector) assert instance + count >= 0 instance += count selector.update_instance(instance) return UiObject(self.session, selector) @property def count(self): return self.jsonrpc.count(self.selector) def __len__(self): return self.count def __iter__(self): obj, length = self, self.count class Iter(object): def __init__(self): self.index = -1 def next(self): self.index += 1 if self.index < length: return obj[self.index] else: raise StopIteration() __next__ = next return Iter() def right(self, **kwargs): def onrightof(rect1, rect2): left, top, right, bottom = intersect(rect1, rect2) return rect2["left"] - rect1["right"] if top < bottom else -1 return self.__view_beside(onrightof, **kwargs) def left(self, **kwargs): def onleftof(rect1, rect2): left, top, right, bottom = intersect(rect1, rect2) return rect1["left"] - rect2["right"] if top < bottom else -1 return self.__view_beside(onleftof, **kwargs) def up(self, **kwargs): def above(rect1, rect2): left, top, right, bottom = intersect(rect1, rect2) return rect1["top"] - rect2["bottom"] if left < right else -1 return self.__view_beside(above, **kwargs) def down(self, **kwargs): def under(rect1, rect2): left, top, right, bottom = intersect(rect1, rect2) return rect2["top"] - rect1["bottom"] if left < right else -1 return self.__view_beside(under, **kwargs) def __view_beside(self, onsideof, **kwargs): bounds = self.info["bounds"] min_dist, found = -1, None info_list = UiObject(self.session, Selector(**kwargs)).info_list() for index, info in enumerate(info_list): dist = onsideof(bounds, info["bounds"]) if dist >= 0 and (min_dist < 0 or dist < min_dist): ui_selector = Selector(**kwargs) ui_selector.update_instance(index) min_dist, found = dist, UiObject(self.session, ui_selector) return found @property def fling(self): """ Args: dimention (str): one of "vert", "vertically", "vertical", "horiz", "horizental", "horizentally" action (str): one of "forward", "backward", "toBeginning", "toEnd", "to" """ jsonrpc = self.jsonrpc selector = self.selector class _Fling(object): def __init__(self): self._vertical = True self.action = 'forward' def __getattr__(self, key): if key in ["horiz", "horizental", "horizentally"]: self._vertical = False return self if key in ['vert', 'vertically', 'vertical']: self._vertical = True return self if key in [ "forward", "backward", "toBeginning", "toEnd", "to" ]: self.action = key return self raise ValueError("invalid prop %s" % key) def __call__(self, max_swipes=500, **kwargs): if self.action == "forward": return jsonrpc.flingForward(selector, self._vertical) elif self.action == "backward": return jsonrpc.flingBackward(selector, self._vertical) elif self.action == "toBeginning": return jsonrpc.flingToBeginning(selector, self._vertical, max_swipes) elif self.action == "toEnd": return jsonrpc.flingToEnd(selector, self._vertical, max_swipes) return _Fling() @property def scroll(self): """ Args: dimention (str): one of "vert", "vertically", "vertical", "horiz", "horizental", "horizentally" action (str): one of "forward", "backward", "toBeginning", "toEnd", "to" """ selector = self.selector jsonrpc = self.jsonrpc class _Scroll(object): def __init__(self): self._vertical = True self.action = 'forward' def __getattr__(self, key): if key in ["horiz", "horizental", "horizentally"]: self._vertical = False return self if key in ['vert', 'vertically', 'vertical']: self._vertical = True return self if key in [ "forward", "backward", "toBeginning", "toEnd", "to" ]: self.action = key return self raise ValueError("invalid prop %s" % key) def __call__(self, steps=SCROLL_STEPS, max_swipes=500, **kwargs): # More steps slows the swipe and prevents contents from being flung too far if self.action in ["forward", "backward"]: method = jsonrpc.scrollForward if self.action == "forward" else jsonrpc.scrollBackward return method(selector, self._vertical, steps) elif self.action == "toBeginning": return jsonrpc.scrollToBeginning(selector, self._vertical, max_swipes, steps) elif self.action == "toEnd": return jsonrpc.scrollToEnd(selector, self._vertical, max_swipes, steps) elif self.action == "to": return jsonrpc.scrollTo(selector, Selector(**kwargs), self._vertical) return _Scroll() ================================================ FILE: uiautomator2/abstract.py ================================================ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """Created on Thu Apr 25 2024 15:08:43 by codeskyblue """ import abc import typing from typing import Any, List, NamedTuple, Tuple, Union import adbutils from PIL import Image from uiautomator2._proto import Direction class ShellResponse(NamedTuple): output: str exit_code: int class AbstractUiautomatorServer(abc.ABC): @abc.abstractmethod def start_uiautomator(self): pass @abc.abstractmethod def stop_uiautomator(self): pass @abc.abstractmethod def jsonrpc_call(self, method: str, params: Any = None) -> Any: pass class AbstractShell(abc.ABC): @abc.abstractmethod def shell(self, cmdargs: Union[List[str], str]) -> ShellResponse: pass @property @abc.abstractmethod def adb_device(self) -> adbutils.AdbDevice: pass @property @abc.abstractmethod def jsonrpc(self) -> typing.Any: pass class AbstractXPathBasedDevice(metaclass=abc.ABCMeta): @abc.abstractmethod def click(self, x: int, y: int): pass @abc.abstractmethod def long_click(self, x: int, y: int): pass @abc.abstractmethod def send_keys(self, text: str): pass @abc.abstractmethod def swipe(self, fx: int, fy: int, tx: int, ty: int, duration: float): """ duration is float type, indicate seconds """ @abc.abstractmethod def swipe_ext(self, direction: Direction, scale: float): pass @abc.abstractmethod def window_size(self) -> Tuple[int, int]: """ return (width, height) """ @abc.abstractmethod def dump_hierarchy(self) -> str: """ return xml content """ @abc.abstractmethod def screenshot(self) -> Image.Image: """ return PIL.Image.Image """ ================================================ FILE: uiautomator2/assets/.gitignore ================================================ *.apk atx-agent version.json *.jar ================================================ FILE: uiautomator2/assets/sync.sh ================================================ #!/bin/bash # set -e APK_VERSION=$(cat ../version.py| grep apk_version | awk '{print $NF}') APK_VERSION=${APK_VERSION//[\"\']} JAR_VERSION="0.2.2" cd "$(dirname $0)" function download() { local URL=$1 local OUTPUT=$2 echo ">> download $URL -> $OUTPUT" curl -L "$URL" --output "$OUTPUT" } function download_apk(){ local VERSION=$1 local NAME=$2 local URL="https://github.com/openatx/android-uiautomator-server/releases/download/$VERSION/$NAME" download "$URL" "$NAME" unzip -tq "$NAME" } function download_jar() { local URL="https://public.uiauto.devsleep.com/u2jar/$JAR_VERSION/u2.jar" https_proxy= download "$URL" "u2.jar" if test -s u2.jar; then echo "Download Jar sucessfully" else echo "Download Jar failed" exit 1 fi } echo "APK_VERSION: $APK_VERSION" download_jar download_apk "$APK_VERSION" "app-uiautomator.apk" cat > version.json < str: return self.__serial def _wait_for_device(self, timeout=10) -> adbutils.AdbDevice: """ wait for device came online, if device is remote, reconnect every 1s Returns: adbutils.AdbDevice Raises: ConnectError """ for d in adbutils.adb.device_list(): if d.serial == self._serial: return d _RE_remote_adb = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d+$") _is_remote = _RE_remote_adb.match(self._serial) is not None adb = adbutils.adb deadline = time.time() + timeout while time.time() < deadline: title = "device reconnecting" if _is_remote else "wait-for-device" logger.debug("%s, time left(%.1fs)", title, deadline - time.time()) if _is_remote: try: adb.disconnect(self._serial) adb.connect(self._serial, timeout=1) except (adbutils.AdbError, adbutils.AdbTimeout) as e: logger.debug("adb reconnect error: %s", str(e)) time.sleep(1.0) continue try: adb.wait_for(self._serial, timeout=1) except (adbutils.AdbError, adbutils.AdbTimeout): continue return adb.device(self._serial) raise ConnectError(f"device {self._serial} not online") @property def adb_device(self) -> adbutils.AdbDevice: return self._dev @cached_property def settings(self) -> Settings: return Settings(self) def sleep(self, seconds: float): """ same as time.sleep """ time.sleep(seconds) def shell(self, cmdargs: Union[str, List[str]], timeout=60) -> ShellResponse: """ Run shell command on device Args: cmdargs: str or list, example: "ls -l" or ["ls", "-l"] timeout: seconds of command run, works on when stream is False Returns: ShellResponse Raises: AdbShellError """ try: if self.debug: print("shell:", list2cmdline(cmdargs)) logger.debug("shell: %s", list2cmdline(cmdargs)) ret = self._dev.shell2(cmdargs, timeout=timeout) return ShellResponse(ret.output, ret.returncode) except adbutils.AdbError as e: raise AdbShellError(e) @property def info(self) -> Dict[str, Any]: return self.jsonrpc.deviceInfo(http_timeout=10) @property def device_info(self) -> Dict[str, Any]: serial = self._dev.getprop("ro.serialno") sdk = self._dev.getprop("ro.build.version.sdk") version = self._dev.getprop("ro.build.version.release") brand = self._dev.getprop("ro.product.brand") model = self._dev.getprop("ro.product.model") arch = self._dev.getprop("ro.product.cpu.abi") return { "serial": serial, "sdk": int(sdk) if sdk.isdigit() else None, "brand": brand, "model": model, "arch": arch, "version": int(version) if version.isdigit() else None, } @property def wlan_ip(self) -> Optional[str]: try: return self._dev.wlan_ip() except adbutils.AdbError: return None @property def jsonrpc(self): class JSONRpcWrapper(): def __init__(self, server: BasicUiautomatorServer): self.server = server self.method = None def __getattr__(self, method): self.method = method # jsonrpc function name return self def __call__(self, *args, **kwargs): http_timeout = kwargs.pop('http_timeout', HTTP_TIMEOUT) params = args if args else kwargs return self.server.jsonrpc_call(self.method, params, http_timeout) return JSONRpcWrapper(self) def reset_uiautomator(self): """ restart uiautomator service Orders: - stop uiautomator keeper - am force-stop com.github.uiautomator - start uiautomator keeper(am instrument -w ...) - wait until uiautomator service is ready """ self.stop_uiautomator() self.start_uiautomator() def push(self, src, dst: str, mode=0o644): """ Push file into device Args: src (path or fileobj): source file dst (str): destination can be folder or file path mode (int): file mode """ self._dev.sync.push(src, dst, mode=mode) def pull(self, src: str, dst: str): """ Pull file from device to local """ try: self._dev.sync.pull(src, dst, exist_ok=True) except TypeError: self._dev.sync.pull(src, dst) ================================================ FILE: uiautomator2/core.py ================================================ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """Created on Thu Apr 25 2024 14:50:05 by codeskyblue """ import atexit import datetime import hashlib import json import logging import os import threading import time from http.client import HTTPConnection from pathlib import Path from typing import Any, Dict, Optional, Union import adbutils import requests from uiautomator2.abstract import AbstractUiautomatorServer from uiautomator2.exceptions import AccessibilityServiceAlreadyRegisteredError, APKSignatureError, HTTPError, \ HTTPTimeoutError, LaunchUiAutomationError, RPCInvalidError, RPCStackOverflowError, RPCUnknownError, \ UiAutomationNotConnectedError, UiObjectNotFoundError from uiautomator2.utils import with_package_resource from uiautomator2.version import __apk_version__ logger = logging.getLogger(__name__) class MockAdbProcess: def __init__(self, conn: adbutils.AdbConnection) -> None: self._conn = conn self._event = threading.Event() self._output = bytearray() def wait_finished(): try: while chunk := self._conn.conn.recv(1024): logger.debug("MockAdbProcess: %s", chunk) self._output.extend(chunk) except: pass self._event.set() t = threading.Thread(target=wait_finished) t.daemon = True t.name = "wait_adb_conn" t.start() @property def output(self) -> bytes: """ subprocess do not have this property """ return self._output def wait(self) -> bool: return self._event.wait(timeout=3) def pool(self) -> Optional[int]: if self._event.is_set(): return 0 return None def kill(self): self._conn.close() self.wait() def launch_uiautomator(dev: adbutils.AdbDevice) -> MockAdbProcess: """Launch uiautomator2 server on device""" command = "CLASSPATH=/data/local/tmp/u2.jar app_process / com.wetest.uia2.Main" logger.debug("launch uiautomator with cmd: %s", command) conn = dev.shell(command, stream=True) process = MockAdbProcess(conn) return process class HTTPResponse: def __init__(self, content: bytes) -> None: self.content = content def json(self): return json.loads(self.content) @property def text(self): return self.content.decode("utf-8", errors="ignore") class AdbHTTPConnection(HTTPConnection): def __init__(self, device: adbutils.AdbDevice, port=9008): super().__init__("localhost", port) self.__device = device self.__port = port def connect(self): try: self.sock = self.__device.create_connection(adbutils.Network.TCP, self.__port) except adbutils.AdbError as e: raise HTTPError(f"Unable to connect to uiautomator2 server: {e}") from e def __enter__(self) -> HTTPConnection: return self def __exit__(self, exc_type, exc_value, traceback): self.close() def _http_request(dev: adbutils.AdbDevice, device_port: int, method: str, path: str, data: Optional[Dict[str, Any]] = None, timeout=10.0, print_request: bool = False) -> HTTPResponse: """Send http request to uiautomator2 server""" try: logger.debug("http request %s %s %s", method, path, data) # https://stackoverflow.com/questions/2386299/running-sites-on-localhost-is-extremely-slow # so here use 127.0.0.1 instead of localhost if print_request: start_time = datetime.datetime.now() current_time = start_time.strftime("%H:%M:%S.%f")[:-3] url = f"http://127.0.0.1:{device_port}{path}" fields = [current_time, f"$ curl -X {method}", url] if data: fields.append(f"-d '{json.dumps(data)}'") print(f"# http timeout={timeout}") print(" ".join(fields)) # set Accept-Encoding to empty to avoid gzip compression # nanohttpd gzip has resource leaks # https://github.com/NanoHttpd/nanohttpd/issues/492 # https://blog.csdn.net/fcp12138/article/details/80436644 headers = { 'User-Agent': 'uiautomator2', 'Accept-Encoding': '', 'Content-Type': 'application/json' } with AdbHTTPConnection(dev, port=device_port) as conn: conn.timeout = timeout if not data: conn.request(method, path, headers=headers) else: conn.request(method, path, json.dumps(data), headers=headers) _response = conn.getresponse() content = bytearray() while chunk := _response.read(4096): content.extend(chunk) if _response.status != 200: raise HTTPError(f"HTTP request failed: {_response.status} {_response.reason}") response = HTTPResponse(content) if print_request: end_time = datetime.datetime.now() current_time = end_time.strftime("%H:%M:%S.%f")[:-3] print(f"{current_time} Response >>>") print(response.text.rstrip()) print(f"<<< END timed_used = %.3f\n" % (end_time - start_time).total_seconds()) return response except requests.Timeout as e: raise HTTPTimeoutError(f"HTTP request timeout: {e}") from e except requests.RequestException as e: raise HTTPError(f"HTTP request failed: {e}") from e def _jsonrpc_call(dev: adbutils.AdbDevice, device_port: int, method: str, params: Any, timeout: float, print_request: bool) -> Any: """Send jsonrpc call to uiautomator2 server Raises: UiAutomationError """ payload = { "jsonrpc": "2.0", "id": 1, "method": method, "params": params } r = _http_request(dev, device_port, "POST", "/jsonrpc/0", payload, timeout=timeout, print_request=print_request) data = r.json() if not isinstance(data, dict): raise RPCInvalidError("Unknown RPC error: not a dict") if isinstance(data, dict) and "error" in data: logger.debug("jsonrpc error: %s", data) code = data['error'].get('code') message = data['error'].get('message', '') stacktrace = data['error'].get('data') if "UiAutomation not connected" in r.text: raise UiAutomationNotConnectedError("UiAutomation not connected") if "android.os.DeadObjectException" in message: # https://developer.android.com/reference/android/os/DeadObjectException raise UiAutomationNotConnectedError("android.os.DeadObjectException") if "android.os.DeadSystemRuntimeException" in message: raise UiAutomationNotConnectedError("android.os.DeadSystemRuntimeException") if "uiautomator.UiObjectNotFoundException" in message: raise UiObjectNotFoundError(code, message, params) if "java.lang.StackOverflowError" in message: raise RPCStackOverflowError(f"StackOverflowError: {message}", params, stacktrace[:1000] + "..." + stacktrace[-1000:]) raise RPCUnknownError(f"Unknown RPC error: {code} {message}", params, stacktrace) if "result" not in data: raise RPCInvalidError("Unknown RPC error: no result field") return data["result"] class BasicUiautomatorServer(AbstractUiautomatorServer): """ Simple uiautomator2 server client this is runs without atx-agent """ _lock = threading.Lock() # thread safe lock def __init__(self, dev: adbutils.AdbDevice, device_server_port: int = 9008) -> None: self._dev = dev self._process = None self._debug = False self._device_server_port = device_server_port self.start_uiautomator() atexit.register(self.stop_uiautomator, wait=False) @property def debug(self) -> bool: return self._debug @debug.setter def debug(self, value: bool): self._debug = bool(value) def start_uiautomator(self): """ Start uiautomator2 server Raises: LaunchUiautomatorError: uiautomator2 server not ready """ with self._lock: self._setup_jar() if self._process: if self._process.pool() is not None: self._process = None if not self._check_alive(): self._process = launch_uiautomator(self._dev) self._wait_ready() def _setup_jar(self): with with_package_resource("assets/u2.jar") as jar_path: target_path = "/data/local/tmp/u2.jar" if self._check_device_file_hash(jar_path, target_path): logger.debug("file u2.jar already pushed") else: logger.debug("push %s -> %s", jar_path, target_path) self._dev.sync.push(jar_path, target_path, check=True) def _check_device_file_hash(self, local_file: Union[str, Path], remote_file: str) -> bool: """ check if remote file hash is correct """ md5 = hashlib.md5() with open(local_file, "rb") as f: md5.update(f.read()) local_md5 = md5.hexdigest() logger.debug("file %s md5: %s", os.path.basename(local_file), local_md5) output = self._dev.shell(["toybox", "md5sum", remote_file]) if "toybox" in output and "not found" in output: output = self._dev.shell(["md5", remote_file]) return local_md5 in output def _wait_ready(self, launch_timeout=30): """Wait until uiautomator2 server is ready""" self._wait_app_process_ready(launch_timeout) def _wait_app_process_ready(self, timeout: float): """ ERROR1: [server] INFO: [UiAutomator2Server] Starting Server java.lang.IllegalStateException: UiAutomationService android.accessibilityservice.IAccessibilityServiceClient$Stub$Proxy@5deffd5already registered! NORMAL: [server] INFO: [UiAutomator2Server] Starting Server SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder". SLF4J: Defaulting to no-operation (NOP) logger implementation SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details. """ deadline = time.time() + timeout output_buffer = '' while time.time() < deadline: output = self._process.output.decode("utf-8", errors="ignore") output_buffer += output if "already registered" in output: raise AccessibilityServiceAlreadyRegisteredError(output) if self._process.pool() is not None: raise LaunchUiAutomationError("server quit unexpectly", output_buffer) if self._check_alive(): return time.sleep(.5) raise LaunchUiAutomationError("server not ready", output_buffer) def _check_alive(self) -> bool: try: response = _http_request(self._dev, self._device_server_port, "GET", "/ping") return response.content == b"pong" except (HTTPError, ConnectionError): return False def stop_uiautomator(self, wait=True): with self._lock: if self._process: self._process.kill() self._process = None # wait server quit if wait: deadline = time.time() + 10 while time.time() < deadline: if not self._check_alive(): return time.sleep(.5) def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any: """Send jsonrpc call to uiautomator2 server""" try: return _jsonrpc_call(self._dev, self._device_server_port, method, params, timeout, self._debug) except (HTTPError, UiAutomationNotConnectedError) as e: logger.debug("uiautomator2 is not ok, error: %s", e) self.stop_uiautomator() self.start_uiautomator() return _jsonrpc_call(self._dev, self._device_server_port, method, params, timeout, self._debug) ================================================ FILE: uiautomator2/exceptions.py ================================================ # coding: utf-8 # # BaseException # +- RPCError # | +- RPCUnknownError # | +- RPCInvalidError # | +- HierarchyEmptyError # | +- RPCStackOverflowError # | +- NormalError # | +- XPathElementNotFoundError # | +- UiObjectNotFoundError # | +- AppNotFoundError # | +- SessionBrokenError # +- DeviceError # +- InputIMEError # +- HTTPError # +- ConnectError # +- AdbShellError # +- AdbBroadcastError # +- APKSignatureError # +- UiAutomationError # +- UiAutomationNotConnectedError # +- InjectPermissionError # +- LaunchUiAutomationError # +- AccessibilityServiceAlreadyRegisteredError class BaseException(Exception): """ base error for uiautomator2 """ ## DeviceError class DeviceError(BaseException): ... class AdbShellError(DeviceError):... class ConnectError(DeviceError):... class HTTPError(DeviceError):... class HTTPTimeoutError(HTTPError):... class AdbBroadcastError(DeviceError):... class UiAutomationError(DeviceError):... class InputIMEError(DeviceError):... class UiAutomationNotConnectedError(UiAutomationError):... class InjectPermissionError(UiAutomationError):... #开发者选项中: 模拟点击没有打开 class APKSignatureError(UiAutomationError):... class LaunchUiAutomationError(UiAutomationError):... class AccessibilityServiceAlreadyRegisteredError(UiAutomationError):... ## RPCError class RPCError(BaseException): pass class RPCUnknownError(RPCError):... class RPCInvalidError(RPCError):... class HierarchyEmptyError(RPCError):... class RPCStackOverflowError(RPCError):... class NormalError(RPCError): pass class XPathElementNotFoundError(NormalError):... class SessionBrokenError(NormalError):... #only happens when app quit or crash class UiObjectNotFoundError(NormalError):... class AppNotFoundError(NormalError):... ================================================ FILE: uiautomator2/ext/__init__.py ================================================ ================================================ FILE: uiautomator2/ext/htmlreport/README.md ================================================ # HTMLReport for uiautomator2 Demo code ```python # coding: utf-8 import uiautomator2 as u2 import uiautomator2.ext.htmlreport as htmlreport u = u2.connect() hrp = htmlreport.HTMLReport(u) # take screenshot before each click hrp.patch_click() u.click(0.4, 0.6) u.click(0.4, 0.5) u(text="Github").click() # will also record ``` ## Screenshot ![Alt](../../../docs/img/htmlreport.png) ## LICENSE MIT ================================================ FILE: uiautomator2/ext/htmlreport/__init__.py ================================================ # coding: utf-8 # from __future__ import print_function import functools import inspect import json import os import shutil import sys import time import types from PIL import ImageDraw import uiautomator2 def mark_point(im, x, y): """ Mark position to show which point clicked Args: im: pillow.Image """ draw = ImageDraw.Draw(im) w, h = im.size draw.line((x, 0, x, h), fill='red', width=5) draw.line((0, y, w, y), fill='red', width=5) r = min(im.size) // 40 draw.ellipse((x - r, y - r, x + r, y + r), fill='red') r = min(im.size) // 50 draw.ellipse((x - r, y - r, x + r, y + r), fill='white') del draw return im class HTMLReport(object): def __init__(self, driver, target_dir='report'): self._driver = driver self._target_dir = target_dir self._steps = [] self._copy_assets() self._flush() def _copy_assets(self): # py3 can use os.makedirs(dst, exist_ok=True), but py2 cannot if not os.path.exists(self._target_dir): os.makedirs(self._target_dir) sdir = os.path.dirname(os.path.abspath(__file__)) for file in ['index.html', 'simplehttpserver.py', 'start.bat']: src = os.path.join(sdir, 'assets', file) dst = os.path.join(self._target_dir, file) shutil.copyfile(src, dst) def _record_screenshot(self, pos=None): """ Save screenshot and add record into record.json Example record data: { "time": "2017/1/2 10:20:30", "code": "d.click(100, 800)", "screenshot": "imgs/demo.jpg" } """ im = self._driver.screenshot() if pos: x, y = pos im = mark_point(im, x, y) im.thumbnail((800, 800)) relpath = os.path.join('imgs', 'img-%d.jpg' % (time.time() * 1000)) abspath = os.path.join(self._target_dir, relpath) dstdir = os.path.dirname(abspath) if not os.path.exists(dstdir): os.makedirs(dstdir) im.save(abspath) self._addtosteps(dict(screenshot=relpath)) def _addtosteps(self, data): """ Args: data: dict used to save into record.json """ codelines = [] for stk in inspect.stack()[1:]: filename = stk[1] try: filename = os.path.relpath(filename) except ValueError: # Windows: maybe on other driver, eg: C:/ F:/ continue if filename.find("/site-packages/") != -1: # Linux continue if filename.startswith(".."): # only select files under curdir continue # --- stack --- # 0: the frame object # 1: the filename # 2: the line number of the current line # 3: the function name # 4: a list of lines of context from the source code # 5: the index of the current line within that list. codeline = '%s:%d\n %s' % (filename, stk[2], ''.join(stk[4] or []).strip()) codelines.append(codeline) code = '\n'.join(codelines) steps = self._steps base_data = { 'time': time.strftime("%H:%M:%S"), 'code': code, } base_data.update(data) steps.append(base_data) self._flush() def _flush(self): record_file = os.path.join(self._target_dir, 'record.json') with open(record_file, 'wb') as f: f.write(json.dumps({'steps': self._steps}).encode('utf-8')) def _patch_instance_func(self, obj, name, newfunc): """ patch a.funcname to new func """ oldfunc = getattr(obj, name) print("mock", oldfunc) newfunc = functools.wraps(oldfunc)(newfunc) newfunc.oldfunc = oldfunc setattr(obj, name, types.MethodType(newfunc, obj)) def _patch_class_func(self, obj, funcname, newfunc): """ patch A.funcname to new func """ oldfunc = getattr(obj, funcname) if hasattr(oldfunc, 'oldfunc'): raise RuntimeError("function: %s.%s already patched before" % (obj, funcname)) newfunc = functools.wraps(oldfunc)(newfunc) newfunc.oldfunc = oldfunc setattr(obj, funcname, newfunc) def _unpatch_func(self, obj, funcname): curfunc = getattr(obj, funcname) if hasattr(curfunc, 'oldfunc'): setattr(obj, funcname, curfunc.oldfunc) return True def patch_click(self): """ Record every click operation into report. """ def _mock_click(obj, x, y): x, y = obj.pos_rel2abs(x, y) self._record_screenshot((x, y)) # write image and record.json return obj.click.oldfunc(obj, x, y) def _mock_long_click(obj, x, y, duration=None): x, y = obj.pos_rel2abs(x, y) self._record_screenshot((x, y)) # write image and record.json return obj.long_click.oldfunc(obj, x, y, duration) self._patch_class_func(uiautomator2.Session, 'click', _mock_click) self._patch_class_func(uiautomator2.Session, 'long_click', _mock_long_click) def unpatch_click(self): """ Remove record for click operation """ self._unpatch_func(uiautomator2.Session, 'click') self._unpatch_func(uiautomator2.Session, 'long_click') ================================================ FILE: uiautomator2/ext/htmlreport/assets/index.html ================================================ U2 Report
{{index+1}} {{v.time}}
{{v.code}}

Make mobile test automated, free testers from endless work.

================================================ FILE: uiautomator2/ext/htmlreport/assets/simplehttpserver.py ================================================ #!/usr/bin/env python # coding: utf-8 import http.server as SimpleHTTPServer import socket import socketserver as SocketServer import webbrowser from contextlib import closing def is_port_avaiable(port): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) result = sock.connect_ex(('127.0.0.1', port)) return result != 0 def free_port(): if is_port_avaiable(11000): return 11000 with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: s.bind(('', 0)) return s.getsockname()[1] def main(): PORT = free_port() Handler = SimpleHTTPServer.SimpleHTTPRequestHandler httpd = SocketServer.TCPServer(("", PORT), Handler) # There is a bug that you have to refresh web page so you can see htmlreport # Even I tried to use threading to delay webbrowser open tab # but still need to refresh to let report show up. # I guess this is SimpleHTTPServer bug webbrowser.open('http://127.0.0.1:%d' % PORT, new=2) print("serving at port", PORT) httpd.serve_forever(0.1) if __name__ == '__main__': main() ================================================ FILE: uiautomator2/ext/htmlreport/assets/start.bat ================================================ python -u simplehttpserver.py ================================================ FILE: uiautomator2/ext/info/__init__.py ================================================ import atexit import datetime import json import os from uiautomator2 import UIAutomatorServer from uiautomator2.ext.info import conf class Info(object): def __init__(self, driver, package_name=None): self._driver = driver self.output_dir = 'report/' self.pkg_name = package_name self.test_info = {} atexit.register(self.write_info) def read_file(self, filename): try: with open(self.output_dir + filename, 'r') as f: return f.read() except IOError as e: print(os.strerror(e.errno)) def get_basic_info(self): device_info = self._driver.device_info app_info = self._driver.app_info(self.pkg_name) # query for exact model info if device_info['model'] in conf.phones: device_info['model'] = conf.phones[device_info['model']] self.test_info['basic_info'] = {'device_info': device_info, 'app_info': app_info} def get_app_icon(self): icon = self._driver.app_icon(self.pkg_name) icon.save(self.output_dir + 'icon.png') def get_record_info(self): record = json.loads(self.read_file('record.json')) steps = len(record['steps']) start_time = datetime.datetime.strptime(record['steps'][0]['time'], '%H:%M:%S') end_time = datetime.datetime.strptime( record['steps'][steps - 1]['time'], '%H:%M:%S') total_time = end_time - start_time self.test_info['record_info'] = { 'steps': steps, 'start_time': record['steps'][0]['time'], 'total_time': str(total_time) } def get_result_info(self): log = self.read_file('log.txt') trace_list = [] if log: log = log.splitlines() for i in range(len(log)): if 'Traceback' in log[i]: new_trace = log[i] i += 1 while 'File' in log[i]: new_trace += '\n' + log[i] i += 1 new_trace += '\n' + log[i] trace_list.append(new_trace) self.test_info['trace_info'] = { 'trace_count': len(trace_list), 'trace_list': trace_list } def start(self): self.get_basic_info() self.get_app_icon() def write_info(self): # self.get_basic_info() self.get_record_info() self.get_result_info() with open(self.output_dir + 'info.json', 'wb') as f: f.write(json.dumps(self.test_info)) ================================================ FILE: uiautomator2/ext/info/conf.py ================================================ #! /usr/bin/env python # -*- coding:utf-8 -*- # Author: ljw phones = {"1107": "OPPO 1107", "15 Plus": "魅族 15 Plus", "15": "魅族 15", "1501-A02": "360奇酷 F4", "1503-A01": "360奇酷 N4", "1505-A01": "360奇酷 N4S", "1505-A02": "360 N4S", "1515-A01": "360奇酷 Q5", "16 X": "魅族 16X", "1603-A03": "360奇酷 N4A", "1605-A01": "360奇酷 N5", "1607-A01": "360奇酷 N5S", "16th Plus": "魅族 16th Plus", "16th": "魅族 16th", "1707-A01": "360 N6", "1801-A01": "360 N6 Pro", "1803-A01": "360 N7 Lite", "1807-A01": "360 N7", "1809-A01": "360 N7 Pro", "2013022": "红米 手机", "2014011": "红米 1S", "2014501": "红米 1S ", "2014813": "红米 2A", "401SO": "索尼 Xperia Z3 ", "506SH": "夏普 AQUOS Xx3", "831C": "HTC One M8", "8676-A01": "酷派 大神 Note 3", "8681-M02": "360奇酷 青春版", "8692-A00": "360奇酷 旗舰版", "A0001": "一加 1", "A11": "OPPO A11", "A31": "OPPO A31", "ALE-UL00": "华为 P8 青春版", "ALP-AL00": "华为 Mate 10", "ANE-AL00": "华为 nova 3e", "ARE-AL00": "华为 荣耀 8X Max", "ASUS_T00J": "华硕 ZenFone 5", "ASUS_X550": "华硕 飞马2 Plus", "ASUS_Z00UD": "Zenfone Selfie", "ASUS_Z012DE": "华硕 ZenFone 3", "ASUS_Z016DA": "华硕 ZenFone 3", "ASUS_Z01QD": "华硕 ROG", "ATH-AL00": "华为 荣耀 7i", "ATH-UL00": "华为 荣耀 7i", "ATU-AL10": "华为 畅享 8e", "AUM-AL20": "华为 荣耀畅玩 7A", "BAC-TL00": "华为 nova 2 Plus", "BKL-AL00": "华为 荣耀 V10", "BKL-AL20": "华为 荣耀 V10", "BLA-AL00": "华为 Mate 10 Pro", "BLN-AL10": "华为 荣耀畅玩 6X", "BLN-AL30": "华为 荣耀畅玩 6X", "BLN-AL40": "华为 荣耀畅玩 6X", "BLN-TL10": "华为 荣耀畅玩 6X", "BND-AL10": "华为 荣耀畅玩 7X", "BTV-W09": "华为 MediaPad M3", "C105": "酷派 cool S1", "C106": "酷派 cool1", "C107-9": "酷派 cool 1C", "C2105": "索尼 C2105", "C8817D": "华为 荣耀 畅玩4", "CAM-AL00": "华为 荣耀畅玩 5A", "CAM-TL00": "华为 荣耀畅玩5A", "CHE-TL00": "华为 荣耀 畅玩 4X", "Che1-CL20": "华为 荣耀畅玩 4X", "Che2-TL00M": "华为 荣耀 畅玩 4X", "CHM-CL00": "华为 荣耀 4C 电信版", "CHM-UL00": "华为 荣耀 4C", "CLT-TL01": "华为 P20 Pro", "COL-AL10": "华为 荣耀 10", "Coolpad 8297-T01": "酷派 大神 F1", "Coolpad 8297W": "酷派 大神 F1", "Coolpad 8675": "酷派 大神 F2 移动版", "Coolpad 8675-A": "酷派 大神 F2", "Coolpad 8702D": "酷派 8720D", "Coolpad 8705": "酷派 8705", "Coolpad Y75": "酷派 锋尚", "Coolpad Y803-9": "酷派 锋尚3", "Coolpad Y80D": "酷派 锋尚", "COR-AL00": "华为 荣耀Play", "CUN-TL00": "华为 荣耀畅玩 5", "d-02H": "华为 docomo dtab Compact", "DE106": "坚果 R1", "DIG-AL00": "华为 畅享 6S", "DLI-AL10": "华为 荣耀畅玩 6A", "DOOV L9": "朵唯 L9", "DOOV S2": "朵唯 S2", "DRA-AL00": "华为 畅享 8e ", "DUA-AL00": "华为 荣耀畅玩 7", "DUK-AL20": "华为 荣耀 V9", "E6": "金立 E6", "E6533": "索尼 Xperia Z3", "E6653": "索尼 Xperia Z", "E6883": "索尼 Xperia Z5", "E6mini": "金立 E6 mini", "E7": "金立 E7", "EDI-AL10": "华为 荣耀 Note8", "EML-AL00": "华为 P20", "EVA-AL00": "华为 P9", "EVR-AL00": "华为 Mate 20 X", "F-01H": "富士通 ARROWS ", "F-01J": "富士通 ARROWS", "F-02G": "富士通 F-02G", "F-02H": "富士通 ARROWS NX ", "F-04G": "富士通 ARROWS NX", "F-04H": "ARROWS Tab", "F100": "金立 F100", "F100S": "金立 F100", "F103": "金立 F103", "F106": "金立 F106", "F301": "金立 F301", "F8332": "索尼 Xperia XZ", "FIG-AL00": "华为 畅享 7S", "FLA-AL10": "华为 畅享 8 Plus", "FRD-AL00": "华为 荣耀 8", "FRD-AL10": "华为 荣耀 8", "FS8010": "夏普 AQUOS S2", "G620S-UL00": "华为 荣耀 畅玩4", "G8232": "索尼 Xperia XZs", "GEM-703L": "华为 荣耀 X2", "GIONEE F205L": "金立 F205", "GIONEE F6L": "金立 F6", "GIONEE GN5007": "金立 大金刚 2", "GIONEE M7": "金立 M7", "GIONEE S10": "金立 S10", "GIONEE S11": "金立 S11", "GIONEE S11S": "金立 S11s", "GN151": "金立 GN151", "GN5001S": "金立 金钢", "GN5003": "金立 大金钢", "GN5005": "金立 金钢 2", "GN8002S": "金立 M6 Plus", "GN8003": "金立 M6", "GN9000": "金立 S5.5", "GN9000L": "金立 S5.5L", "GN9005": "金立 S5.1", "GN9006": "金立 S7", "GN9012": "金立 S6 Pro", "GRA-A0": "酷派 酷玩 6C", "GT-810": "掠夺者8 A5002", "GT-I9060I": "三星 Galaxy Grand Neo Plus", "GT-I9152": "三星 GT-I9152", "GT-I9158P": "三星 Galaxy Mega", "GT-I9208": "三星 GT-I9208", "GT-I9300": "三星 Galaxy S3", "GT-I9500": "三星 GalaxyS4", "GT-I9507V": "三星 Galaxy S4", "GT-I9508": "三星 Galaxy S4", "GT-N7100": "三星 Galaxy Note 2", "GT-N7108": "三星 Galaxy Note 2", "H30-T00": "华为 荣耀3C", "H30-T10": "华为 荣耀3C", "H30-U10": "华为 荣耀3C", "H60-L01": "华为 荣耀 6", "H60-L03": "华为 荣耀 6", "H8296": "索尼 Xperia XZ2", "Hisense A2": "海信 A2", "Hisense E76": "海信 E76", "HLA NOTE1-L": "红辣椒 Note", "HM 1S": "红米 1S", "HM 2A": "红米 2A", "HM NOTE 1LTE": "红米 Note", "HM NOTE 1S": "红米 Note", "HM NOTE 1TD": "红米 Note", "HTC 2Q4D200": "HTC U11+", "HTC 2Q4R400": "HTC U11 EYEs", "HTC 2Q55300": "HTC U12+", "HTC 802w": "HTC One M7", "HTC 8088": "HTC One max", "HTC 9088": "HTC Butterfly S", "HTC A9w": "HTC One A9", "HTC D10w": "HTC Desire 10 Pro", "HTC D816w": "HTC Desire 816", "HTC D820u": "HTC D820u", "HTC D830u": "HTC Desire 830", "HTC Desire EYE": "HTC Desire Eye", "HTC M10u": "HTC 10", "HTC M8t": "HTC One E8", "HTC One A9": "HTC One A9", "HTC One M9PLUS": "HTC M9 台版", "HTC One X9 dual sim": "HTC One X9", "HTC U Ultra": "HTC U Ultra 港版", "HTC U-1w": "HTC U Ultra", "HTC_M10h": "HTC 10", "HTC_M9u": "HTC One M9", "HUAWEI CAZ-AL10": "华为 nova", "HUAWEI CAZ-TL20": "华为 nova", "HUAWEI G606-T00": "华为 G606-T00", "HUAWEI G610-U00": "华为 G610", "HUAWEI G7-TL00": "华为 G7", "HUAWEI G700-T00": "华为 G700-T00", "HUAWEI G700-U00": "华为 G700-U00", "HUAWEI G750-T01": "华为 荣耀3X", "HUAWEI GRA-CL10": "华为 P8 高配版", "HUAWEI GRA-TL00": "华为 P8", "HUAWEI GRA-UL00": "华为 P8", "HUAWEI M2-801W": "华为 MediaPad", "HUAWEI M2-A01W": "华为 MediaPad", "HUAWEI MLA-AL00": "华为 麦芒 5", "HUAWEI MLA-TL10": "华为 G9 Plus", "HUAWEI MLA-UL00": "华为 G9 Plus", "HUAWEI MT2-L01": "华为 Mate 2", "HUAWEI MT7-TL00": "华为 Mate 7", "HUAWEI MT7-TL10": "华为 Mate 7", "HUAWEI NXT-AL10": "华为 Mate 8", "HUAWEI NXT-DL00": "华为 Mate 8", "HUAWEI P6 S-U06": "华为 P6 S", "HUAWEI P6-T00": "华为 P6", "HUAWEI P7-L00": "华为 P7", "HUAWEI P7-L05": "华为 P7", "HUAWEI P7-L07": "华为 P7", "HUAWEI P8max": "华为 P8max", "HUAWEI RIO-AL00": "华为 麦芒 4", "HUAWEI RIO-CL00": "华为 麦芒 4", "HUAWEI RIO-TL00": "华为 G7 Plus", "HUAWEI TAG-AL00": "华为 畅享 5S", "HUAWEI TAG-TL00": "华为 畅享 5S", "HUAWEI TIT-AL00": "华为 畅享 5", "HUAWEI VNS-AL00": "华为 G9", "HUAWEI VNS-L21": "华为 P9 Lite", "HUAWEI VNS-TL00": "华为 G9", "HWI-AL00": "华为 nova 2S", "HWT31": "华为 au Qua tab", "IM-A870L": "泛泰 VEGA", "INE-AL00": "华为 nova 3i", "JMM-AL00": "华为 荣耀 V9 Play", "JSN-AL00a": "华为 荣耀 8X", "K011": "华硕 Memo Pad", "KIW-AL10": "华为 荣耀畅玩 5X", "KIW-TL00H": "华为 荣耀畅玩 5X", "KNT-AL10": "华为 荣耀 V8", "KNT-TL10": "华为 荣耀 V8", "KOB-W09": "华为 荣耀畅玩", "KYT31": "KYOCERA au Qua tab", "L36h": "索尼 Z", "L39h": "索尼 Xperia Z1", "L50w": "索尼 Xperia Z2", "LA7-L": "小辣椒 7", "LDN-AL00": "华为 畅享 8", "Le X507": "乐视 乐1s", "Le X620": "乐视 乐2", "Le X625": "乐视 乐2 Pro", "Le X820": "乐视 乐Max 2", "Lenovo A788t": "联想 A788t", "Lenovo A808t": "联想 黄金斗士A8", "Lenovo A808t-i": "联想 黄金斗士A8", "Lenovo A850": "联想 A850", "Lenovo K30-T": "联想 乐檬 K3", "Lenovo K900": "联想 K900", "Lenovo K920": "联想 K920", "Lenovo L78011": "联想 Z5", "Lenovo P2c72": "联想 VIBE P2", "Lenovo P70-t": "联想 P70t", "Lenovo PB2-690N": "联想 Phab2 Pro", "Lenovo S60-t": "联想 S60t", "Lenovo S850t": "联想 S850t", "Lenovo S868t": "联想 S868t", "Lenovo S90-t": "联想 笋尖S90", "Lenovo X2-TO": "联想 VIBE X2", "Letv X500": "乐视 乐1s", "LEX720": "乐视 乐Pro3", "LG-D486": "LG D486", "LG-D855": "LG G3", "LG-E985T": "LG E985T", "LG-H818": "LG G4", "LG-H860": "LG G5", "LG-H968": "LG V10", "LG-M250": "LG K10 2017", "LGMS210": "LG Aristo MS210", "LGT31": "LGT 31", "LGV32": "LG G4", "LLD-AL00": "华为 荣耀 9 青春版", "LLD-AL20": "华为 荣耀 9i", "LND-AL30": "华为 荣耀畅玩 7C", "LON-AL00": "华为 Mate 9 Pro", "LYA-AL00": "华为 Mate 20 Pro", "M040": "魅族 MX2", "M1 E": "魅族 魅蓝 E", "m1 metal": "魅族 魅蓝 Metal", "m1 note": "魅族 魅蓝 Note", "m1": "魅族 魅蓝", "M15": "魅族 M15", "M1813": "魅族 V8 高配版", "M1816": "魅族 V8 标准版", "M1852": "魅族 X8", "M2 E": "魅族 魅蓝 E2", "m2 note": "魅族 魅蓝 Note 2", "m2": "魅族 魅蓝 2", "M3 Max": "魅族 魅蓝 Max", "m3 note": "魅族 魅蓝 Note 3", "M351": "魅族 MX3", "M3s": "魅族 魅蓝 3S", "M3X": "魅族 魅蓝 X", "M463C": "魅族 魅蓝 Note 电信版", "M5 Note": "魅族 魅蓝 Note 5", "M5": "魅族 魅蓝 5", "M5s": "魅族 魅蓝 5S", "M6 Note": "魅族 魅蓝 Note 6", "M6": "魅族 魅蓝 6", "M623C": "中国移动 A1", "M651CY": "中国移动 A3", "M760": "中国移动 A4s", "Meitu M4": "美图 M4", "Meizu 6T": "魅族 魅蓝 6T", "MEIZU E3": "魅族 魅蓝 E3", "Meizu S6": "魅族 魅蓝 S6", "MHA-AL00": "华为 Mate 9", "MHA-L29": "华为 Mate 9 国际版", "MI 2": "小米 2", "MI 2A": "小米 2A", "MI 3": "小米 3 移动版", "MI 3C": "小米 3 电信版", "MI 3W": "小米 3 联通版", "MI 4LTE": "小米 4 ", "MI 4S": "小米 4S", "MI 4W": "小米 4 联通版", "MI 5": "小米 5", "MI 5C": "小米 5C", "MI 5s Plus": "小米 5S Plus", "MI 5s": "小米 5S", "MI 5X": "小米 5X", "MI 6": "小米 6", "MI 6X": "小米 6X", "MI 8 Explorer Edition": "小米 8 透明探索版", "MI 8 Lite": "小米 8 青春版", "MI 8 SE": "小米 8 SE", "MI 8 UD": "小米 8", "MI 8": "小米 8", "Mi A1": "小米 A1", "MI MAX 2": "小米 MAX 2", "MI MAX 3": "小米 Max 3", "MI MAX": "小米 Max", "Mi Note 2": "小米 Note 2", "Mi Note 3": "小米 Note 3", "MI NOTE LTE": "小米 Note", "MI NOTE Pro": "小米 Note 顶配版", "MI PAD 2": "小米 平板2", "MI PAD 3": "小米 平板3", "MI PAD 4 PLUS": "小米 平板4 Plus", "MI PAD 4": "小米 平板4", "MI PAD": "小米 平板", "Mi-4c": "小米 4C 标准版", "MI-ONE Plus": "小米 1", "MIX 2": "小米 MIX 2", "MIX 2S": "小米 MIX 2S", "MIX 3": "小米 MIX 3", "MIX": "小米 MIX", "Moto E (4)": "Moto E", "Moto G (5) Plus": "Moto G5 Plus", "Moto G (5)": "Moto G5", "Moto X Pro": "Moto X Pro", "MP1503": "美图 M6", "MX4 Pro": "魅族 MX4 Pro", "MX4": "魅族 MX4", "MX5": "魅族 MX5", "MX6": "魅族 MX6", "MYA-AL10": "华为 荣耀畅玩 6", "MYA-L22": "华为 Y5", "N1": "诺基亚 N1", "N1T": "OPPO N1T", "N5207": "OPPO N3", "NCE-AL00": "华为 畅享 6", "NCE-AL10": "华为 畅享 6", "NCE-TL10": "华为 畅享 6", "NEM-AL10": "华为 荣耀 畅玩5C", "NEM-TL00": "华为 荣耀 畅玩5C", "NEM-TL00H": "华为 荣耀 畅玩5C", "NEO-AL00": "华为 Mate RS", "Nexus 4": "Google Nexus 4", "Nexus 5": "Google Nexus 5", "Nexus 5X": "Google Nexus 5X", "Nexus 6": "Google Nexus 6", "Nexus 6P": "Google Nexus 6P", "Nexus 9": "Google Nexus 9", "Nokia 7 plus": "诺基亚 7 Plus", "Nokia 8 Sirocco": "诺基亚 8 Sirocco", "Nokia X5": "诺基亚 X5", "Nokia X6": "诺基亚 X6", "NTS-AL00": "华为 荣耀 Magic", "NX403A": "努比亚 Z5S mini", "NX508J": "努比亚 Z9", "NX513J": "努比亚 My", "NX529J": "努比亚 Z11 mini", "NX531J": "努比亚 Z11", "NX563J": "努比亚 Z17", "NX569J": "努比亚 Z17 mini ", "NX573J": "努比亚 M2", "NX575J": "努比亚 N2", "NX595J": "努比亚 Z17S", "NX609J": "努比亚 红魔手机", "NXT-AL10": "华为 Mate 8", "OC105": "锤子 坚果 3", "OD103": "锤子 坚果 Pro", "OE106": "锤子 坚果 Pro 2S", "ONE A2001": "一加 2", "ONEPLUS A3000": "一加 3", "ONEPLUS A3010": "一加 3T", "ONEPLUS A5000": "一加 5", "ONEPLUS A5010": "一加 5T", "ONEPLUS A6000": "一加 6", "OPPO A30": "OPPO A30", "OPPO A33": "OPPO A33", "OPPO A33m": "OPPO A33", "OPPO A37m": "OPPO A37", "OPPO A53": "OPPO A53", "OPPO A53m": "OPPO A53", "OPPO A57": "OPPO A57", "OPPO A59m": "OPPO A59", "OPPO A59s": "OPPO A59s", "OPPO A73": "OPPO A73", "OPPO A77": "OPPO A77", "OPPO A79": "OPPO A79", "OPPO A83": "OPPO A83", "OPPO R11 Plus": "OPPO R11 Plus", "OPPO R11 Plusk": "OPPO R11 Plus", "OPPO R11": "OPPO R11", "OPPO R11s Plus": "OPPO R11s Plus", "OPPO R11s": "OPPO R11s", "OPPO R11st": "OPPO R11s", "OPPO R11t": "OPPO R11", "OPPO R7": "OPPO R7", "OPPO R7s": "OPPO R7s ", "OPPO R7sm": "OPPO R7s 全网通", "OPPO R9 Plustm A": "OPPO R9 Plus", "OPPO R9m": "OPPO R9", "OPPO R9s Plus": "OPPO R9s Plus", "OPPO R9s": "OPPO R9s", "OPPO R9sk": "OPPO R9s", "OPPO R9tm": "OPPO R9", "OS105": "锤子 坚果 Pro 2", "PAAM00": "OPPO R15", "PADM00": "OPPO A3", "PAFM00": "OPPO Find X", "PAR-AL00": "华为 nova 3", "PBAM00": "OPPO A5", "PBEM00": "OPPO R17", "PE-CL00": "华为 荣耀 6 Plus", "PE-TL10": "华为 荣耀 6 Plus", "PH-1": "Essential Phone", "PIC-AL00": "华为 nova 2", "Pixel 2 XL": "Google Pixel 2 XL", "Pixel XL": "Google Pixel XL", "Pixel": "Google Pixel", "PLAYER": "小辣椒 Player", "PLK-CL00": "华为 荣耀 7", "PLK-TL01H": "华为 荣耀 7", "PLK-UL00": "华为 荣耀 7", "POCOPHONE F1": "POCOPHONE F1", "PP5600": "PPTV M1", "PRA-AL00": "华为 荣耀 8 青春版", "PRA-AL00X": "华为 荣耀 8 青春版", "PRO 5": "魅族 Pro 5", "PRO 6 Plus": "魅族 Pro 6 Plus", "PRO 6": "魅族 Pro 6", "PRO 6s": "魅族 Pro 6s", "PRO 7 Plus": "魅族 Pro 7 Plus", "PRO 7-S": "魅族 Pro 7", "R2017": "OPPO R2017", "R6007": "OPPO R6007", "R7007": "OPPO R3", "R7c": "OPPO R7 电信版", "R7Plus": "OPPO R7 Plus 移动版", "R7Plusm": "OPPO R7 Plus 全网通", "R8107": "OPPO R8107", "R819T": "OPPO R819T", "R8207": "OPPO R1C", "R821T": "OPPO R821T", "R831S": "OPPO R831s", "R831T": "OPPO R831T", "ramos MOS 1": "蓝魔 MOS1", "ramos MOS1max": "蓝魔 MOS1 MAX", "Redmi 3": "红米 3", "Redmi 3S": "红米 3S", "Redmi 3X": "红米 3X", "Redmi 4": "红米 4", "Redmi 4A": "红米 4A", "Redmi 4X": "红米 4X", "Redmi 5 Plus": "红米 5 Plus", "Redmi 5": "红米 5", "Redmi 5A": "红米 5A", "Redmi 6 Pro": "红米 6 Pro", "Redmi 6A": "红米 6A", "Redmi Note 2": "红米 Note 2", "Redmi Note 3": "红米 Note 3", "Redmi Note 4": "红米 Note 4", "Redmi Note 4X": "红米 Note 4X", "Redmi Note 5": "红米 Note 5", "Redmi Note 5A": "红米 Note 5A", "Redmi Pro": "红米 Pro", "Redmi S2": "红米 S2", "RNE-AL00": "华为 麦芒 6", "RVL-AL09": "华为 荣耀 Note10", "S39h": "索尼 Xperia C", "S9": "金立 S9", "SAMSUNG-SM-G930A": "三星 Galaxy S7 美版", "SC-03J": "三星 Galaxy S8", "SC-04E": "三星 Galaxy S4", "SC-04F": "三星 Galaxy S5", "SC-04G": "三星 Galaxy S6 Edg", "SC-04J": "三星 Galaxy Feel", "SC-05G": "三星 Galaxy S6", "SCH-I939I": "三星 Galaxy S3 Neo+", "SCL-AL00": "华为 荣耀 4A", "SCV31": "三星 Galaxy S6 Edge", "SCV35": "三星 Galaxy S8+", "SCV36": "三星 Galaxy S8", "SD4930UR": "Amzon Fire Phone", "SGH-N075T": "三星 Galaxy J", "SKR-A0": "黑鲨 游戏手机", "SLA-AL00": "华为 畅享 7", "SM-A3000": "三星 Galaxy A3", "SM-A5000": "三星 Galaxy A5", "SM-A5100": "三星 Galaxy A5", "SM-A520S": "三星 Galaxy A5", "SM-A7000": "三星 Galaxy A7", "SM-C5010": "三星 Galaxy C5 Pro", "SM-C7000": "三星 Galaxy C7", "SM-C7010": "三星 Galaxy C7 Pro", "SM-C7100": "三星 Galaxy C8", "SM-C9000": "三星 Galaxy C9 Pro", "SM-E7000": "三星 Galaxy E7", "SM-G5309W": "三星 Galaxy GRAND", "SM-G530H": "三星 Galaxy Grand Prime ", "SM-G532F": "三星 Grand Prime Plus", "SM-G5500": "三星 Galaxy On5", "SM-G6000": "三星 Galaxy On7", "SM-G7108V": "三星 Galaxy Grand 2", "SM-G8508S": "三星 Galaxy Alpha", "SM-G8750": "三星 Galaxy S ", "SM-G9006W": "三星 Galaxy S5", "SM-G9008V": "三星 Galaxy S5", "SM-G900V": "三星 Galaxy S5", "SM-G910S": "三星 Galaxy Round", "SM-G9200": "三星 Galaxy S6", "SM-G920F": "三星 Galaxy S6", "SM-G920V": "三星 Galaxy S6", "SM-G9250": "三星 Galaxy S6 Edge", "SM-G925V": "三星 Galaxy S6 Edge", "SM-G9280": "三星 Galaxy S6 Edge+", "SM-G9300": "三星 Galaxy S7", "SM-G9308": "三星 Galaxy S7", "SM-G930F": "三星 Galaxy S7", "SM-G9350": "三星 Galaxy S7 Edge", "SM-G935F": "三星 Galaxy S7 Edge", "SM-G935V": "三星 Galaxy S7 Edge", "SM-G9500": "三星 Galaxy S8", "SM-G950F": "三星 Galaxy S8", "SM-G9550": "三星 Galaxy S8+", "SM-G955N": "三星 Galaxy S8+", "SM-G9600": "三星 Galaxy S9", "SM-G960N": "三星 Galaxy S9 ", "SM-G960U": "三星 Galaxy S9", "SM-G965N": "三星 Galaxy S9+", "SM-G965U": "三星 Galaxy S9+", "SM-J5008": "三星 Galaxy J5", "SM-J7008": "三星 Galaxy J7", "SM-J701F": "三星 Galaxy J7", "SM-J7109": "三星 Galaxy J7", "SM-J730GM": "三星 Galaxy J7 Pro", "SM-N9002": "三星 Galaxy Note 3", "SM-N9008V": "三星 Galaxy Note 3", "SM-N900A": "三星 Galaxy Note 3", "SM-N9100": "三星 Galaxy Note 4", "SM-N910U": "三星 Galaxy Note 4", "SM-N9200": "三星 Galaxy Note 5", "SM-N9208": "三星 Galaxy Note 5", "SM-N920K": "三星 Galaxy Note 5", "SM-N9500": "三星 Galaxy Note 8", "SM-N950N": "三星 Galaxy Note 8", "SM-N950U1": "三星 Galaxy Note 8", "SM-N9600": "三星 Galaxy Note 9", "SM-P601": "三星 Galaxy Note 10.1", "SM-T110": "三星 Galaxy Tab3", "SM-T113": "三星 Tab E Lite", "SM-T310": "三星 GALAXY Tab3", "SM-T560": "三星 Galaxy Tab E", "SM-T580": "三星 Galaxy Tab A", "SM-T817V": "三星 Galaxy Tab S2", "SM701": "锤子 T1", "SM901": "锤子 M1", "SM919": "锤子 M1L", "SNE-AL00": "华为 麦芒 7", "SO-03G": "索尼 Xperia Z4", "SO-04H": "索尼 Xperia X Performance", "SO-04J": "索尼 Xperia XZ Premium", "SOV31": "索尼 Xperia Z4", "SOV35": "索尼 Xperia XZs", "STF-AL00": "华为 荣耀 9", "STF-AL10": "华为 荣耀 9", "TA-1000": "诺基亚 6", "TA-1041": "诺基亚 7", "TA-1052": "诺基亚 8", "TCL 520": "TCL 520", "TCL 750": "TCL 750", "TRT-AL00": "华为 畅享 7 Plus", "U10": "魅族 魅蓝 U10", "V1732A": "vivo Y81s", "V1809A": "vivo X23", "V1813A": "vivo Y97", "V182": "金立 V182", "V183": "金立 V183", "V185": "金立 V185", "V188S": "金立 V188S", "V8": "天语 V8", "VCR-A0": "酷派 酷玩 6", "VIE-AL10": "华为 P9 Plus", "Vivo 5R": "BLU vivo 5R", "vivo NEX A": "vivo NEX 标准版", "vivo NEX S": "vivo NEX 旗舰版", "vivo V3": "vivo V3", "vivo V3M A": "vivo V3M", "vivo V3Max A": "vivo V3Max", "vivo V3Max": "vivo V3Max", "vivo X20A": "vivo X20", "vivo X20Plus A": "vivo X20 Plus", "vivo X20Plus UD": "vivo X20 PlusUD", "vivo X20Plus": "vivo X20 Plus", "vivo X21A": "vivo X21", "vivo X21i A": "vivo X21i", "vivo X21UD A": "vivo X21UD", "vivo X3L": "vivo X3L", "vivo X3t": "vivo X3T", "vivo X5L": "vivo X5L", "vivo X5M": "vivo X5M", "vivo X5Max V": "vivo X5 Max V", "vivo X5Max+": "vivo X5Max+", "vivo X5Pro D": "vivo X5Pro", "vivo X5Pro V": "vivo X5Pro V", "vivo X5S L": "vivo X5 SL", "vivo X6A": "vivo X6A", "vivo X6D": "vivo X6D", "vivo X6Plus A": "vivo X6 Plus A", "vivo X6Plus D": "vivo X6 Plus", "vivo X6Plus L": "vivo X6 Plus", "vivo X6S A": "vivo X6S", "vivo X6SPlus D": "vivo X6S Plus", "vivo X7": "vivo X7", "vivo X7Plus": "vivo X7 Plus", "vivo X9": "vivo X9", "vivo X9i": "vivo X9", "vivo X9Plus": "vivo X9 Plus", "vivo X9s Plus": "vivo X9s Plus", "vivo X9s": "vivo X9s", "vivo Xplay3S": "vivo Xplay 3S", "vivo Xplay5A": "vivo Xplay5", "vivo Xplay6": "vivo Xplay6", "vivo Y11i T": "vivo Y11iT", "vivo Y13L": "vivo Y13L", "vivo Y23L": "vivo Y23L", "vivo Y27": "vivo Y27", "vivo Y29L": "vivo Y29L", "vivo Y31A": "vivo Y31A", "vivo Y33": "vivo Y33", "vivo Y35": "vivo Y35L", "vivo Y35A": "vivo Y35A", "vivo Y35L": "vivo Y35L", "vivo Y37": "vivo Y37", "vivo Y51A": "vivo Y51A", "vivo Y53": "vivo Y53", "vivo Y55": "vivo Y55A", "vivo Y66": "vivo Y66", "vivo Y67": "vivo Y67", "vivo Y67A": "vivo Y67", "vivo Y69A": "vivo Y69", "vivo Y71A": "vivo Y71", "vivo Y75": "vivo Y75", "vivo Y75s": "vivo Y75s", "vivo Y79A": "vivo Y79", "vivo Y83A": "vivo Y83(HIH-PHO-3360)", "vivo Y85A": "vivo Y85", "vivo Y913": "vivo Y13L", "vivo Z1": "vivo Z1(HIH-PHO-3368)", "vivo Z1i": "vivo Z1i(HIH-PHO-3506)", "vivo": "vivo Y75", "VKY-AL00": "华为 P10 Plus", "VTR-AL00": "华为 P10", "W800": "金立 天鉴 w800", "WAS-AL00": "华为 nova 青春版", "X9007": "OPPO Find 7 轻装版", "X9077": "OPPO Find 7", "XT1052": "Moto X(一代)", "XT1060": "Moto X(一代)", "XT1079": "Moto G 二代", "XT1085": "Moto X 二代", "XT1096": "Moto X 二代", "XT1570": "Moto X Style", "XT1635-03": "Moto Z Play", "XT1650": "Moto Z", "XT1650-05": "Moto Z", "XT1662": "Moto M(XT1662)", "XT1710-08": "Moto Z2 Play", "XT1789-05": "Moto Z 2018", "XT1799-2": "Moto 青柚", "XT907": "Moto XT907", "Y13L": "vivo Y13L", "Y23L": "vivo Y23L", "Y35A": "vivo Y35A", "YQ601": "锤子 坚果手机", "Z999": "中兴 Axon M", "ZTE A0722": "中兴 Blade A4", "ZTE BA610C": "中兴 远航4", "ZTE BV0710": "中兴 V7 MAX", "ZTE BV0720": "中兴 Blade A2", "ZTE BV0730": "中兴 Blade A2 Plus", "ZTE C2017": "中兴 天机7Max", "ZTE C880U": "中兴 Blade A1", "ZTE U930HD": "中兴 U930 HD", "ZTE V0900": "中兴 Blade V9", "ZTE V975": "中兴 V975", "ZUK Z1": "ZUK Z1(Z1221)", "ZUK Z2121": "ZUK Z2 Pro(Z2121)", "ZUK Z2131": "ZUK Z2", "ZUK Z2151": "ZUK Edge" } ================================================ FILE: uiautomator2/ext/perf/README.md ================================================ # Performance 性能采集 自动记录测试过程中的CPU,PSS, NET 使用方法 ```python import uiautomator2 as u2 import uiautomator2.ext.perf as perf package_name = "com.netease.cloudmusic" u2.plugin_register('perf', perf.Perf) def main(): d = u2.connect() d.ext_perf.package_name = package_name d.ext_perf.csv_output = "perf.csv" # 保存数据到perf.csv # d.debug = True # 采集到数据就输出,默认关闭 # d.interval = 1.0 # 数据采集间隔,默认1.0s,尽量不要小于0.5s,因为采集内存比较费时间 d.ext_perf.start() # run ... tests code here ... d.ext_perf.stop() # 最好结束的时候调用下,虽然不调用也没多大关系 # generate images from csv # 需要安装 matplotlib, pandas, numpy, humanize d.ext_perf.csv2images() if __name__ == '__main__': main() ``` 保存的csv文件内容格式为 ```csv time,package,pss,cpu,systemCpu,rxBytes,txBytes,fps 2018-09-11 20:35:29.016,com.tencent.tmgp.sgame,456.71,13.3,15.8,0,0,12.8 2018-09-11 20:35:29.733,com.tencent.tmgp.sgame,456.75,11.0,20.6,108,160,30.7 2018-09-11 20:35:30.756,com.tencent.tmgp.sgame,456.83,12.2,18.9,548,2021,31.3 2018-09-11 20:35:31.730,com.tencent.tmgp.sgame,457.05,11.6,19.1,160,1199,29.8 2018-09-11 20:35:32.759,com.tencent.tmgp.sgame,457.05,11.7,19.5,108,160,31.1 2018-09-11 20:35:33.821,com.tencent.tmgp.sgame,456.86,11.6,17.7,0,0,29.2 ``` 生成的图片为 ![net](net.png) ![summary](summary.png) `csv2images`函数更多的用法 ```python d.ext_perf.csv2images("perf.csv", target_dir="./") ``` 数据项说明 - PSS直接通过`dumpsys meminfo `获取 - CPU直接读取的`/proc/`下的文件计算出来的,多核的情况,数据是有可能超过100%的 - rxBytes, txBytes 目前只有wlan的流量,tcp和udp的流量总和 - fps 通过解析`dumpsys SurfaceFlinger --list` 和 `dumpsys SurfaceFlinger --latency ` 计算出来 ## 参考资料 - [Python CSV读写方法](https://python3-cookbook.readthedocs.io/zh_CN/latest/c06/p01_read_write_csv_data.html) - [android屏幕刷新显示机制](https://blog.csdn.net/litefish/article/details/53939882) - [Android FPS计算方法](https://www.jianshu.com/p/1fe9783d266b) - [Github项目@leekinwa-androidTestTools_performance_FPS](https://github.com/leekinwa/androidTestTools_Performance_FPS) - [官方proc文件格式资料](http://man7.org/linux/man-pages/man5/proc.5.html) - [Chromium有关FPS的计算方法](https://github.com/ChromiumWebApps/chromium/blob/master/build/android/pylib/perf/surface_stats_collector.py) - [FPS 计算方法的比较 by fenfenzhong](https://testerhome.com/topics/4643) - [安卓性能测试之cpu占用率统计方法总结](https://www.jianshu.com/p/6bf564f7cdf0) - [Android 性能测试实践 (四) 流量](https://testerhome.com/topics/2643) ================================================ FILE: uiautomator2/ext/perf/__init__.py ================================================ # coding: utf-8 # from __future__ import absolute_import, print_function import atexit import csv import datetime import os import re import sys import threading import time from collections import namedtuple _MEM_PATTERN = re.compile(r'TOTAL[:\s]+(\d+)') # acct_tag_hex is a socket tag # cnt_set==0 are for background data # cnt_set==1 are for foreground data _NetStats = namedtuple( "NetStats", """idx iface acct_tag_hex uid_tag_int cnt_set rx_bytes rx_packets tx_bytes tx_packets rx_tcp_bytes rx_tcp_packets rx_udp_bytes rx_udp_packets rx_other_bytes rx_other_packets tx_tcp_bytes tx_tcp_packets tx_udp_bytes tx_udp_packets tx_other_bytes tx_other_packets""" .split()) class Perf(object): def __init__(self, d, package_name=None): self.d = d self.package_name = package_name self.csv_output = "perf.csv" self.debug = False self.interval = 1.0 self._th = None self._event = threading.Event() self._condition = threading.Condition() self._data = {} def shell(self, *args, **kwargs): # print("Shell:", args) return self.d.shell(*args, **kwargs) def memory(self): """ PSS(KB) """ output = self.shell(['dumpsys', 'meminfo', self.package_name]).output m = _MEM_PATTERN.search(output) if m: return int(m.group(1)) return 0 def _cpu_rawdata_collect(self, pid): """ pjiff maybe 0 if /proc/stat not exists """ first_line = self.shell(['cat', '/proc/stat']).output.splitlines()[0] assert first_line.startswith('cpu ') # ds: user, nice, system, idle, iowait, irq, softirq, stealstolen, guest, guest_nice ds = list(map(int, first_line.split()[1:])) total_cpu = sum(ds) idle = ds[3] proc_stat = self.shell(['cat', '/proc/%d/stat' % pid]).output.split(') ') pjiff = 0 if len(proc_stat) > 1: proc_values = proc_stat[1].split() utime = int(proc_values[11]) stime = int(proc_values[12]) pjiff = utime + stime return (total_cpu, idle, pjiff) def cpu(self, pid): """ CPU Refs: - http://man7.org/linux/man-pages/man5/proc.5.html - [安卓性能测试之cpu占用率统计方法总结](https://www.jianshu.com/p/6bf564f7cdf0) """ store_key = 'cpu-%d' % pid # first time jiffies, t: total, p: process if store_key in self._data: tjiff1, idle1, pjiff1 = self._data[store_key] else: tjiff1, idle1, pjiff1 = self._cpu_rawdata_collect(pid) time.sleep(.3) # second time jiffies self._data[ store_key] = tjiff2, idle2, pjiff2 = self._cpu_rawdata_collect(pid) # calculate pcpu = 0.0 if pjiff1 > 0 and pjiff2 > 0: pcpu = 100.0 * (pjiff2 - pjiff1) / (tjiff2 - tjiff1) # process cpu scpu = 100.0 * ((tjiff2 - idle2) - (tjiff1 - idle1)) / (tjiff2 - tjiff1) # system cpu assert scpu > -1 # maybe -0.5, sometimes happens scpu = max(0, scpu) return round(pcpu, 1), round(scpu, 1) def netstat(self, pid): """ Returns: (rall, tall, rtcp, ttcp, rudp, tudp) """ m = re.search(r'^Uid:\s+(\d+)', self.shell(['cat', '/proc/%d/status' % pid]).output, re.M) if not m: return (0, 0, 0, 0, 0, 0) uid = m.group(1) lines = self.shell(['cat', '/proc/net/xt_qtaguid/stats']).output.splitlines() traffic = [0] * 6 def plus_array(arr, *args): for i, v in enumerate(args): arr[i] = arr[i] + int(v) for line in lines: vs = line.split() if len(vs) != 21: continue v = _NetStats(*vs) if v.uid_tag_int != uid: continue if v.iface != 'wlan0': continue # all, tcp, udp plus_array(traffic, v.rx_bytes, v.tx_bytes, v.rx_tcp_bytes, v.tx_tcp_bytes, v.rx_udp_bytes, v.tx_udp_bytes) store_key = 'netstat-%s' % uid result = [] if store_key in self._data: last_traffic = self._data[store_key] for i in range(len(traffic)): result.append(traffic[i] - last_traffic[i]) self._data[store_key] = traffic return result or [0] * 6 def _current_view(self, app=None): d = self.d views = self.shell(['dumpsys', 'SurfaceFlinger', '--list']).output.splitlines() if not app: app = d.app_current() current = app['package'] + "/" + app['activity'] surface_curr = 'SurfaceView - ' + current if surface_curr in views: return surface_curr return current def _dump_surfaceflinger(self, view): valid_lines = [] MAX_N = 9223372036854775807 for line in self.shell( ['dumpsys', 'SurfaceFlinger', '--latency', view]).output.splitlines(): fields = line.split() if len(fields) != 3: continue a, b, c = map(int, fields) if a == 0: continue if MAX_N in (a, b, c): continue valid_lines.append((a, b, c)) return valid_lines def _fps_init(self): view = self._current_view() self.shell(["dumpsys", "SurfaceFlinger", "--latency-clear", view]) self._data['fps-start-time'] = time.time() self._data['fps-last-vsync'] = None self._data['fps-inited'] = True def fps(self, app=None): """ Return float """ if 'fps-inited' not in self._data: self._fps_init() time.sleep(.2) view = self._current_view(app) values = self._dump_surfaceflinger(view) last_vsync = self._data.get('fps-last-vsync') last_start = self._data.get('fps-start-time') try: idx = values.index(last_vsync) values = values[idx + 1:] except ValueError: pass duration = time.time() - last_start if len(values): self._data['fps-last-vsync'] = values[-1] self._data['fps-start-time'] = time.time() return round(len(values) / duration, 1) def collect(self): pid = self.d._pidof_app(self.package_name) if pid is None: return app = self.d.app_current() pss = self.memory() cpu, scpu = self.cpu(pid) rbytes, tbytes, rtcp, ttcp = self.netstat(pid)[:4] fps = self.fps(app) timestr = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] return { 'time': timestr, 'package': app['package'], 'pss': round(pss / 1024.0, 2), # MB 'cpu': cpu, 'systemCpu': scpu, 'rxBytes': rbytes, 'txBytes': tbytes, 'rxTcpBytes': rtcp, 'txTcpBytes': ttcp, 'fps': fps, } def continue_collect(self, f): try: headers = [ 'time', 'package', 'pss', 'cpu', 'systemCpu', 'rxBytes', 'txBytes', 'rxTcpBytes', 'txTcpBytes', 'fps' ] fcsv = csv.writer(f) fcsv.writerow(headers) update_time = time.time() while not self._event.isSet(): perfdata = self.collect() if self.debug: print("DEBUG:", perfdata) if not perfdata: print("perf package is not alive:", self.package_name) time.sleep(1) continue fcsv.writerow([perfdata[k] for k in headers]) wait_seconds = max(0, self.interval - (time.time() - update_time)) time.sleep(wait_seconds) update_time = time.time() f.close() finally: self._condition.acquire() self._th = None self._condition.notify() self._condition.release() def start(self): csv_dir = os.path.dirname(self.csv_output) if not os.path.isdir(csv_dir): os.makedirs(csv_dir) if sys.version_info.major < 3: f = open(self.csv_output, "wb") else: f = open(self.csv_output, "w", newline='\n') def defer_close(): if not f.closed: f.close() atexit.register(defer_close) if self._th: raise RuntimeError("perf is already running") if not self.package_name: raise EnvironmentError("package_name need to be set") self._data.clear() self._event = threading.Event() self._condition = threading.Condition() self._th = threading.Thread(target=self.continue_collect, args=(f, )) self._th.daemon = True self._th.start() def stop(self): self._event.set() self._condition.acquire() self._condition.wait(timeout=2) self._condition.release() if self.debug: print("DEBUG: perf collect stopped") def csv2images(self, src=None, target_dir='.'): """ Args: src: csv file, default to perf record csv path target_dir: images store dir """ import datetime import os import matplotlib.pyplot as plt import matplotlib.ticker as ticker import pandas as pd from uiautomator2.utils import natualsize src = src or self.csv_output if not os.path.exists(target_dir): os.makedirs(target_dir) data = pd.read_csv(src) data['time'] = data['time'].apply( lambda x: datetime.datetime.strptime(x, "%Y-%m-%d %H:%M:%S.%f")) timestr = time.strftime("%Y-%m-%d %H:%M") # network rx_str = natualsize(data['rxBytes'].sum()) tx_str = natualsize(data['txBytes'].sum()) plt.subplot(2, 1, 1) plt.plot(data['time'], data['rxBytes'] / 1024, label='all') plt.plot(data['time'], data['rxTcpBytes'] / 1024, 'r--', label='tcp') plt.legend() plt.title( '\n'.join( ["Network", timestr, 'Recv %s, Send %s' % (rx_str, tx_str)]), loc='left') plt.gca().xaxis.set_major_formatter(ticker.NullFormatter()) plt.ylabel('Recv(KB)') plt.ylim(ymin=0) plt.subplot(2, 1, 2) plt.plot(data['time'], data['txBytes'] / 1024, label='all') plt.plot(data['time'], data['txTcpBytes'] / 1024, 'r--', label='tcp') plt.legend() plt.xlabel('Time') plt.ylabel('Send(KB)') plt.ylim(ymin=0) plt.savefig(os.path.join(target_dir, "net.png")) plt.clf() plt.subplot(3, 1, 1) plt.title( '\n'.join(['Summary', timestr, self.package_name]), loc='left') plt.plot(data['time'], data['pss'], '-') plt.ylabel('PSS(MB)') plt.gca().xaxis.set_major_formatter(ticker.NullFormatter()) plt.subplot(3, 1, 2) plt.plot(data['time'], data['cpu'], '-') plt.ylim(0, max(100, data['cpu'].max())) plt.ylabel('CPU') plt.gca().xaxis.set_major_formatter(ticker.NullFormatter()) plt.subplot(3, 1, 3) plt.plot(data['time'], data['fps'], '-') plt.ylabel('FPS') plt.ylim(0, 60) plt.xlabel('Time') plt.savefig(os.path.join(target_dir, "summary.png")) if __name__ == '__main__': import uiautomator2 as u2 pkgname = "com.tencent.tmgp.sgame" # pkgname = "com.netease.cloudmusic" u2.plugin_register('perf', Perf, pkgname) d = u2.connect() print(d.app_current()) # print(d.ext_perf.netstat(5350)) # d.app_start(pkgname) d.ext_perf.start() d.ext_perf.debug = True try: time.sleep(500) except KeyboardInterrupt: d.ext_perf.stop() d.ext_perf.csv2images() print("threading stopped") ================================================ FILE: uiautomator2/image.py ================================================ # coding: utf-8 # # Refs: # - https://opencv-python-tutroals.readthedocs.io/en/latest/ import base64 import io import logging import os import re import time import typing from typing import Union import cv2 import findit import imutils import numpy as np import requests from PIL import Image, ImageDraw from skimage.metrics import structural_similarity import uiautomator2 ImageType = typing.Union[np.ndarray, Image.Image] compare_ssim = structural_similarity logger = logging.getLogger(__name__) def color_bgr2gray(image: ImageType): """ change color image to gray Returns: opencv-image """ if ispil(image): image = pil2cv(image) if len(image.shape) == 2: return image return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) def template_ssim(image_a: ImageType, image_b: ImageType): """ Refs: https://opencv-python-tutroals.readthedocs.io/en/latest/py_tutorials/py_imgproc/py_template_matching/py_template_matching.html """ a = color_bgr2gray(image_a) b = color_bgr2gray(image_b) # template (small) res = cv2.matchTemplate(a, b, cv2.TM_CCOEFF_NORMED) min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res) return max_val def cv2crop(im: np.ndarray, bounds: tuple = None): if not bounds: return im assert len(bounds) == 4 lx, ly, rx, ry = bounds crop_img = im[ly:ry, lx:rx] return crop_img def compare_ssim(image_a: ImageType, image_b: ImageType, full=False, bounds=None): a = color_bgr2gray(image_a) b = color_bgr2gray(image_b) # template (small) ca = cv2crop(a, bounds) cb = cv2crop(b, bounds) return structural_similarity(ca, cb, full=full) def compare_ssim_debug(image_a: ImageType, image_b: ImageType, color=(255, 0, 0)): """ Args: image_a, image_b: opencv image or PIL.Image color: (r, g, b) eg: (255, 0, 0) for red Refs: https://www.pyimagesearch.com/2017/06/19/image-difference-with-opencv-and-python/ """ ima, imb = conv2cv(image_a), conv2cv(image_b) score, diff = compare_ssim(ima, imb, full=True) diff = (diff * 255).astype('uint8') _, thresh = cv2.threshold(diff, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) cnts = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cnts = imutils.grab_contours(cnts) cv2color = tuple(reversed(color)) im = ima.copy() for c in cnts: x, y, w, h = cv2.boundingRect(c) cv2.rectangle(im, (x, y), (x+w, y+h), cv2color, 2) # todo: show image cv2pil(im).show() return im def show_image(im: Union[np.ndarray, Image.Image]): pilim = conv2pil(im) pilim.show() def pil2cv(pil_image) -> np.ndarray: """ Convert from pillow image to opencv """ # convert PIL to OpenCV pil_image = pil_image.convert('RGB') cv2_image = np.array(pil_image) # Convert RGB to BGR cv2_image = cv2_image[:, :, ::-1].copy() return cv2_image def pil2base64(pil_image, format="JPEG") -> str: """ Convert pillow image to base64 """ buf = io.BytesIO() pil_image.save(buf, format=format) return base64.b64encode(buf.getvalue()).decode('utf-8') def cv2pil(cv_image): """ Convert opencv to pillow image """ return Image.fromarray(cv_image[:, :, ::-1].copy()) def iscv2(im): return isinstance(im, np.ndarray) def ispil(im): return isinstance(im, Image.Image) def conv2cv(im: Union[np.ndarray, Image.Image]) -> np.ndarray: if iscv2(im): return im if ispil(im): return pil2cv(im) raise TypeError("Unknown image type:", type(im)) def conv2pil(im: Union[np.ndarray, Image.Image]) -> Image.Image: if ispil(im): return im elif iscv2(im): return cv2pil(im) else: raise TypeError(f"Unknown image type: {type(im)}") def _open_data_url(data, flag=cv2.IMREAD_COLOR): pos = data.find('base64,') if pos == -1: raise IOError("data url is invalid, head %s" % data[:20]) pos += len('base64,') raw_data = base64.decodestring(data[pos:]) image = np.asarray(bytearray(raw_data), dtype="uint8") image = cv2.imdecode(image, flag) return image def _open_image_url(url: str, flag=cv2.IMREAD_COLOR): """ download the image, convert it to a NumPy array, and then read it into OpenCV format """ content = requests.get(url).content image = np.asarray(bytearray(content), dtype="uint8") image = cv2.imdecode(image, flag) return image def draw_point(im: Image.Image, x: int, y: int) -> Image.Image: """ Mark position to show which point clicked Args: im: pillow.Image """ draw = ImageDraw.Draw(im) w, h = im.size draw.line((x, 0, x, h), fill='red', width=5) draw.line((0, y, w, y), fill='red', width=5) r = min(im.size) // 40 draw.ellipse((x - r, y - r, x + r, y + r), fill='red') r = min(im.size) // 50 draw.ellipse((x - r, y - r, x + r, y + r), fill='white') del draw return im def imread(data) -> np.ndarray: """ Args: data: local path or http url or data:image/base64,xxx Returns: opencv image Raises: IOError """ if isinstance(data, np.ndarray): return data elif isinstance(data, Image.Image): return pil2cv(data) elif data.startswith('data:image/'): return _open_data_url(data) elif re.match(r'^https?://', data): return _open_image_url(data) elif os.path.isfile(data): im = cv2.imread(data) if im is None: raise IOError("Image format error: %s" % data) return im raise IOError("image read invalid data: %s" % data) class ImageX(object): def __init__(self, d: "uiautomator2.Device"): """ Args: d (uiautomator2 instance) """ self._d = d assert hasattr(d, 'click') assert hasattr(d, 'screenshot') def send_click(self, x, y): return self._d.click(x, y) def getpixel(self, x, y): """ Returns: (r, g, b) """ screenshot = self._d.screenshot() return screenshot.convert("RGB").getpixel((x, y)) def match(self, imdata: Union[np.ndarray, str, Image.Image]): """ Args: imdata: file, url, pillow or opencv image object Returns: templateMatch result """ cvimage = imread(imdata) fi = findit.FindIt(engine=['template'], engine_template_scale=(0.9, 1.1, 3), pro_mode=True) fi.load_template("template", pic_object=cvimage) th, tw = cvimage.shape[:2] # template width, height target = self._d.screenshot(format='opencv') assert isinstance(target, np.ndarray), "screenshot is not opencv format" raw_result = fi.find("target", target_pic_object=target) # from pprint import pprint # pprint(raw_result) result = raw_result['data']['template']['TemplateEngine'] # compress_rate = result['conf']['engine_template_compress_rate'] # useless target_sim = result['target_sim'] # 相似度 similarity x, y = result['target_point'] # this is middle point # x, y = lx+tw//2, ly+th//2 return {"similarity": target_sim, "point": [x, y]} def __wait(self, imdata, timeout=30.0, threshold=0.8): deadline = time.time() + timeout while time.time() < deadline: m = self.match(imdata) sim = m['similarity'] logger.debug("similarity %.2f [~%.2f], left time: %.1fs", sim, threshold, deadline - time.time()) if sim < threshold: continue time.sleep(.1) return m logger.debug("image not found") def wait(self, imdata, timeout=30.0, threshold=0.9): """ wait until image show up """ m = self.__wait(imdata, timeout=timeout, threshold=threshold) return m def click(self, imdata, timeout=30.0, threshold=0.9): """ Args: imdata: file, url, pillow or opencv image object """ res = self.wait(imdata, timeout=timeout, threshold=threshold) if res is None: raise RuntimeError("image object not found") x, y = res['point'] return self.send_click(x, y) def _main(): ima = imread("http://localhost:17310/widgets/00006/template.jpg") imb = imread("http://localhost:17310/widgets/00007/template.jpg") compare_ssim_debug(ima, imb, color=(0, 0, 255)) return im = imread("https://www.baidu.com/img/bd_logo1.png") assert im.shape == (258, 540, 3) print(im.shape) im = imread("../tests/testdata/AE86.jpg") print(im.shape) assert im.shape == (193, 321, 3) pim = cv2pil(im) assert pim.size == (321, 193) taobao = imread("screenshot.jpg") fi = findit.FindIt(engine=['template'], engine_template_scale=(1, 1, 1), pro_mode=True) fi.load_template("template", pic_object=taobao) if __name__ == "__main__": _main() # import uiautomator2 as u2 # d = u2.connect() # bg = d.screenshot(format="opencv") # res = fi.find("target", target_pic_object=bg) # from pprint import pprint # pprint(res) # {'target_name': 'target', # 'target_path': None, # 'data': { # 'template': { # 'TemplateEngine': { # 'conf': { # 'engine_template_cv_method_name': 'cv2.TM_CCOEFF_NORMED', # 'engine_template_cv_method_code': 5, # 'engine_template_scale': (1, 1, 1), # 'engine_template_multi_target_max_threshold': 0.99, # 'engine_template_multi_target_distance_threshold': 10.0, # 'engine_template_compress_rate': 1.0}, # 'target_point': [111, 1713], # 'target_sim': 0.9984192848205566, # 'raw': {'min_val': -0.4805332124233246, # 'max_val': 0.9984192848205566, # 'min_loc': [990, 1266], # 'max_loc': [111, 1713], # 'all': [[111.0, 1713.5]]}, # 'ok': True}}}} # x, y = res["data"]["template"]["TemplateEngine"]["target_point"] # d.click(x, y) ================================================ FILE: uiautomator2/screenrecord.py ================================================ # coding: utf-8 # import re import threading import time import cv2 import imageio import numpy as np from websocket import create_connection import uiautomator2 as u2 def iter_image_from_minicap(uri): ws = create_connection(uri) try: while True: msg = ws.recv() if isinstance(msg, str): print("<-", msg) else: yield msg finally: ws.close() class Screenrecord: def __init__(self, d: u2.Device): self._d = d self._running = False self._stop_event = threading.Event() self._done_event = threading.Event() self._filename = None self._fps = 20 # initial value def __call__(self, *args, **kwargs): self._start(*args, **kwargs) return self def _iter_minicap(self): http_url = self._d.path2url("/minicap") ws_url = re.sub("^http", "ws", http_url) ws = create_connection(ws_url) try: while not self._stop_event.is_set(): msg = ws.recv() if isinstance(msg, str): # print("<-", msg) pass else: yield msg finally: ws.close() def _resize_to(self, im, framesize): """ framesize: tuple of (height, width) """ vh, vw = framesize h, w = im.shape[:2] frame = np.zeros((vh, vw, 3), dtype=np.uint8) # create black background canvas sh = vh / h sw = vw / w if sh < sw: h, w = vh, int(sh * w) else: h, w = int(sw * h), vw left, top = (vw - w) // 2, (vh - h) // 2 frame[top:top + h, left:left + w, :] = cv2.resize(im, dsize=(w, h)) return frame def _pipe_resize(self, image_iter): """ image to same size """ firstim = next(image_iter) yield firstim vh, vw = firstim.shape[:2] for im in image_iter: if im.shape != firstim.shape: im = self._resize_to(im, (vh, vw)) yield im def _pipe_convert(self, raw_iter): # raw data -> imageio for raw in raw_iter: yield imageio.imread(raw) def _pipe_limit(self, raw_iter): findex = 0 fstart = time.time() for raw in raw_iter: elapsed = time.time() - fstart fcount = int(elapsed * self._fps) for _ in range(fcount - findex): yield raw findex = fcount def _run(self): pipelines = [self._pipe_limit, self._pipe_convert, self._pipe_resize] _iter = self._iter_minicap() for p in pipelines: _iter = p(_iter) with imageio.get_writer(self._filename, fps=self._fps) as wr: for im in _iter: wr.append_data(im) self._done_event.set() def _start(self, filename: str, fps: int = 20): if self._running: raise RuntimeError("screenrecord is already started") assert isinstance(fps, int) self._filename = filename self._fps = fps self._running = True th = threading.Thread(name="image2video", target=self._run) th.daemon = True th.start() def stop(self): """ stop record and finish write video Returns: bool: whether video is recorded. """ if not self._running: raise RuntimeError("screenrecord is not started") self._stop_event.set() ret = self._done_event.wait(10.0) # reset self._stop_event.clear() self._done_event.clear() self._running = False return ret ================================================ FILE: uiautomator2/settings.py ================================================ # coding: utf-8 # import logging import pprint from typing import Any logger = logging.getLogger(__name__) class Settings(object): """ 赋值时会检查类型 """ def __init__(self, d): self._d = d self._defaults = { "wait_timeout": 20.0, "xpath_debug": False, "operation_delay": (0, 0), "operation_delay_methods": ["click", "swipe"], "fallback_to_blank_screenshot": False, "max_depth": 50, } self._deprecated_props = { "click_after_delay": "Use operation_delay instead", "click_before_delay": "Use operation_delay instead", "post_delay": None, "uiautomator_runtest_app_background": None, } # 设置变量类型 self._prop_types = { "post_delay": (float, int), "xpath_debug": bool, "fallback_to_blank_screenshot": bool, } for k, v in self._defaults.items(): if k not in self._prop_types: self._prop_types[k] = (float, int) if type(v) in (float, int) else type(v) self._set_methods = { "operation_delay": self.__set_operation_delay, } # self._get_methods = { # "operation_delay": self.__get_operation_delay, # } def __set_operation_delay(self, value: tuple): """ 设置操作的(点击)的前后延时 """ if isinstance(value, (int, float)): value = (value, value) if isinstance(value, (list, tuple)): assert len(value) == 2, "operation_delay only accept list with two items" _pre, post = value assert isinstance(_pre, (int, float)), "operation_delay can only contains int or float" assert isinstance(post, (int, float)), "operation_delay can only contains int or float" self._defaults["operation_delay"] = (_pre, post) def get(self, key: str) -> Any: return self._defaults.get(key) def _set(self, key: str, val: Any): # call from methods if key in self._set_methods: return self._set_methods[key](val) # Deprecated properties if key in self._deprecated_props: reason = self._deprecated_props[key] if not reason: reason = "{} is deprecated".format(key) logger.warning("d.settings[{}] deprecated: {}".format(key, reason)) return # Invalid properties if key not in self._prop_types: raise AttributeError("invalid attribute", key) # Type check if not isinstance(val, self._prop_types[key]): raise TypeError("invalid type, only accept: %r" % self._prop_types[key]) self._defaults[key] = val def __setitem__(self, key: str, val: Any): self._set(key, val) def __getitem__(self, key: str) -> Any: if key not in self._defaults: raise RuntimeError("invalid key", key) return self.get(key) def __repr__(self): return pprint.pformat(self._defaults) # return self._defaults # if __name__ == "__main__": # settings = Settings(None) # settings.set("pre_delay", 10) # print(settings['pre_delay']) # settings["post_delay"] = 10 ================================================ FILE: uiautomator2/swipe.py ================================================ # coding: utf-8 from typing import Optional, Tuple, Union from ._proto import Direction class SwipeExt(object): def __init__(self, d): """ Args: d (uiautomator2.Device) """ self._d = d def __call__(self, direction: Union[Direction, str], scale: float = 0.9, box: Optional[Tuple[int, int, int, int]] = None, **kwargs): """ Args: direction (str): one of "left", "right", "up", "bottom" or Direction.LEFT scale (float): percent of swipe, range (0, 1.0] box (tuple): None or [lx, ly, rx, ry] kwargs: used as kwargs in d.swipe Raises: ValueError """ def _swipe(_from, _to): self._d.swipe(_from[0], _from[1], _to[0], _to[1], **kwargs) if box: lx, ly, rx, ry = box else: lx, ly = 0, 0 rx, ry = self._d.window_size() width, height = rx - lx, ry - ly h_offset = int(width * (1 - scale)) // 2 v_offset = int(height * (1 - scale)) // 2 center = lx + width//2, ly + height//2 left = lx + h_offset, ly + height // 2 up = lx + width // 2, ly + v_offset right = rx - h_offset, ly + height // 2 bottom = lx + width // 2, ry - v_offset if direction == Direction.LEFT: _swipe(right, left) elif direction == Direction.RIGHT: _swipe(left, right) elif direction == Direction.UP: _swipe(center, up) # from center to top elif direction == Direction.DOWN: _swipe(center, bottom) # from center to bottom else: raise ValueError("Unknown direction:", direction) ================================================ FILE: uiautomator2/utils.py ================================================ # coding: utf-8 # import contextlib import functools import inspect import pathlib import shlex import sys import threading import typing import warnings from typing import Iterable, List, Tuple, Union from PIL import Image from uiautomator2._proto import Direction from uiautomator2.exceptions import SessionBrokenError, UiObjectNotFoundError @contextlib.contextmanager def with_package_resource(filename: str) -> typing.Generator[pathlib.Path, None, None]: """ Context manager to access a package asset file using importlib.resources. Args: filename (str): The name of the file to locate. Yields: str: The full path to the located file. """ try: from importlib.resources import as_file, files except ImportError: # For Python < 3.9 from importlib_resources import as_file, files anchor = files("uiautomator2") / filename with as_file(anchor) as f: if f.exists(): yield f return # check if binary folder contains binary_path = pathlib.Path(sys.argv[0]) if (binary_path / filename).exists(): yield binary_path / filename return # check if running program directory contains if (pathlib.Path.cwd() / filename).exists(): yield pathlib.Path.cwd() / filename return raise FileNotFoundError(f"Resource {filename} not found in uiautomator2 package.") def check_alive(fn): @functools.wraps(fn) def inner(self, *args, **kwargs): if not self.running(): raise SessionBrokenError(self._pkg_name) return fn(self, *args, **kwargs) return inner _cached_values = {} def cache_return(fn): @functools.wraps(fn) def inner(*args, **kwargs): key = (fn, args, frozenset(kwargs.items())) value = _cached_values.get(key) if value is not None: return value _cached_values[key] = ret = fn(*args, **kwargs) return ret return inner def hooks_wrap(fn): @functools.wraps(fn) def inner(self, *args, **kwargs): name = fn.__name__.lstrip('_') self.server.hooks_apply("before", name, args, kwargs, None) ret = fn(self, *args, **kwargs) self.server.hooks_apply("after", name, args, kwargs, ret) return inner # Will be removed in the future def wrap_wait_exists(fn): @functools.wraps(fn) def inner(self, *args, **kwargs): timeout = kwargs.pop('timeout', self.wait_timeout) if not self.wait(timeout=timeout): raise UiObjectNotFoundError({ 'code': -32002, 'message': self.selector.__str__() }) return fn(self, *args, **kwargs) return inner def intersect(rect1, rect2): top = rect1["top"] if rect1["top"] > rect2["top"] else rect2["top"] bottom = rect1["bottom"] if rect1["bottom"] < rect2["bottom"] else rect2[ "bottom"] left = rect1["left"] if rect1["left"] > rect2["left"] else rect2["left"] right = rect1["right"] if rect1["right"] < rect2["right"] else rect2[ "right"] return left, top, right, bottom class Exists(object): """Exists object with magic methods.""" def __init__(self, uiobject): self.uiobject = uiobject def __nonzero__(self): """Magic method for bool(self) python2 """ return self.uiobject.jsonrpc.exist(self.uiobject.selector) def __bool__(self): """ Magic method for bool(self) python3 """ return self.__nonzero__() def __call__(self, timeout=0): """Magic method for self(args). Args: timeout (float): exists in seconds """ if timeout: return self.uiobject.wait(timeout=timeout) return bool(self) def __repr__(self): return str(bool(self)) def list2cmdline(args: Union[str, list, tuple]) -> str: if isinstance(args, str): return args return ' '.join(list(map(shlex.quote, args))) def inject_call(fn, *args, **kwargs): """ Call function without known all the arguments Args: fn: function args: arguments kwargs: key-values Returns: as the fn returns """ assert callable(fn), "first argument must be callable" st = inspect.signature(fn) fn_kwargs = { key: kwargs[key] for key in st.parameters.keys() if key in kwargs } ba = st.bind(*args, **fn_kwargs) ba.apply_defaults() return fn(*ba.args, **ba.kwargs) def natualsize(size: int) -> str: _KB = 1 << 10 _MB = 1 << 20 _GB = 1 << 30 if size >= _GB: return '{:.1f} GB'.format(size / _GB) elif size >= _MB: return '{:.1f} MB'.format(size / _MB) else: return '{:.1f} KB'.format(size / _KB) def swipe_in_bounds(d: "uiautomator2.Device", bounds: Tuple[int, int, int, int], direction: Union[Direction, str], scale: float = 0.6): """ Args: d: Device object bounds: list of [lx, ly, rx, ry] direction: one of ["left", "right", "up", "down"] scale: percent of swipe, range (0, 1.0) Raises: AssertionError, ValueError """ def _swipe(_from, _to): d.swipe(_from[0], _from[1], _to[0], _to[1]) assert 0 < scale <= 1.0 assert len(bounds) == 4 lx, ly, rx, ry = bounds width, height = rx - lx, ry - ly h_offset = int(width * (1 - scale)) // 2 v_offset = int(height * (1 - scale)) // 2 left = lx + h_offset, ly + height // 2 up = lx + width // 2, ly + v_offset right = rx - h_offset, ly + height // 2 bottom = lx + width // 2, ry - v_offset if direction == Direction.LEFT: _swipe(right, left) elif direction == Direction.RIGHT: _swipe(left, right) elif direction == Direction.UP: _swipe(bottom, up) elif direction == Direction.DOWN: _swipe(up, bottom) else: raise ValueError("Unknown direction:", direction) def thread_safe_wrapper(fn: typing.Callable): @functools.wraps(fn) def inner(self, *args, **kwargs): if not hasattr(self, "_lock"): self._lock = threading.Lock() with self._lock: return fn(self, *args, **kwargs) return inner def is_version_compatiable(expect_version: str, actual_version: str) -> bool: """ Check if the actual version is compatiable with the expect version Args: expect_version: expect version, e.g. 1.0.0 actual_version: actual version, e.g. 1.0.0 Returns: bool: True if compatiable, otherwise False """ def _parse_version(version: str): return tuple(map(int, version.split("."))) evs = _parse_version(expect_version) avs = _parse_version(actual_version) assert len(evs) == len(avs) == 3, "version format error" if evs[0] == avs[0]: if evs[1] < avs[1]: return True if evs[1] == avs[1]: return evs[2] <= avs[2] return False def deprecated(reason): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): warnings.warn(f"Function '{func.__name__}' is deprecated: {reason}", DeprecationWarning, stacklevel=2) return func(*args, **kwargs) return wrapper return decorator def image_convert(im: Image.Image, format: str): if format == "pillow": return im if format == "opencv": try: import cv2 import numpy as np im = im.convert("RGB") return cv2.cvtColor(np.array(im), cv2.COLOR_RGB2BGR) except ImportError: warnings.warn("missing lib: cv2 or numpy") raise raise ValueError("Unsupported format:", format) ================================================ FILE: uiautomator2/version.py ================================================ # coding: utf-8 # # version managed by poetry __version__ = '0.0.0' # see release note for details __apk_version__ = '2.4.0' # old apk version history # 2.3.3 make float windows smaller # 2.3.2 merge pull requests # require atx-agent>=0.10.0 # 2.3.1 support minicapagent, rotationagent, minitouchagent # 2.2.1 fix click bottom(infinitly display) not working bug # 2.2.0 add MinitouchAgent instead of /data/local/tmp/minitouch # 2.1.1 add show floatWindow support(pm grant, still have no idea), add TC_TREND analysis # 2.0.5 add ToastActivity to show toast or just launch and quit # 2.0.4 fix floatingWindow crash on Sumsung Android 9 # 2.0.3 use android.app.Service instead of android.app.intentService to simpfy logic # 2.0.2 fix error: AndroidQ Service must be explicit # 2.0.1 fix AndroidQ support # 2.0.0 remove runWatchersOnWndowsChange, add setToastListener(bool), add floatWindow # 1.1.7 fix dumpHierarchy XML charactor error # 1.1.6 fix android P support # 1.1.5 waitForExists use UiObject2 method first then fallback to UiObject.waitForExists # 1.1.4 add ADB_EDITOR_CODE broadcast support, fix bug (toast捕获导致app闪退) # 1.1.3 use thread to make watchers.watched faster, try to fix input method type multi # 1.1.2 fix count error when have child && sync watched, to prevent watchers.remove error # 1.1.1 support toast capture # 1.1.0 update uiautomator-v18:2.1.2 -> uiautomator-v18:2.1.3 (This version fixed setWaitIdleTimeout not working bug) # 1.0.14 catch NullException, add gps mock support # 1.0.13 whatsinput suppoort, but not very well # 1.0.12 add toast support # 1.0.11 add auto install support # 1.0.10 fix service not started bug # 1.0.9 fix apk version code and version name # ERR: 1.0.8 bad version number. show ip on notification # ERR: 1.0.7 bad version number. new input method, some bug fix # __jar_version__ = 'v0.1.6' # no useless for now. # v0.1.6 first release version # __atx_agent_version__ = '0.10.1' # sync.sh verison should also be updated # 0.10.1 update androidbinary version, https://github.com/openatx/atx-agent/issues/115 # 0.10.0 remove tunnel code, use androidx.test.runner # 0.9.6 fix security reason for remote control device # 0.9.5 log support rotate, prevent log too large # 0.9.4 test travis push to qiniu-cdn # 0.9.3 fix atx-agent version output too many output # 0.9.2 fix when /sdcard/atx-agent.log can't create, atx-agent can't start error # 0.9.1 update /minicap to use apkagent and minicap # 0.9.0 add /minicap/broadcast api, add service("apkagent") # 0.8.4 use minicap when sdk less than Android Q # 0.8.3 use minitouchagent instead of /data/local/tmp/minitouch # 0.8.2 change am instrument maxRetry from 3 to 1 # 0.8.1 fix --stop can not stop atx-agent error, fix --help format error # 0.8.0 add /newCommandTimeout api, ref: appium-newCommandTimeout # 0.7.4 add /finfo/{filepath:.*} api # 0.7.3 add uiautomator-1.0 support # 0.7.2 fix stop already stopped uiautomator return status 500 error # 0.7.1 fix UIAutomation not connected error. # 0.7.0 add webview support, kill uiautomator if no activity in 3 minutes # 0.6.2 fix app_info fd leak error, update androidbinary to fix parse apk manifest err # 0.6.1 make dump_hierarchy more robust, add cpu,mem collect # 0.6.0 add /dump/hierarchy (works fine even if uiautomator is down) # 0.5.5 add minitouch reset, /screenshot support download param, fix dns error # 0.5.4 upgrade atx-agent to fix apk parse mainActivity of com.tmall.wireless # 0.5.3 try to fix panic in heartbeat # 0.5.2 fix /session/${pkgname} launch timeout too short error(before was 10s) # 0.5.1 bad tag, deprecated # 0.5.0 add /packages/${pkgname}/ api # 0.4.9 update for go1.11 # 0.4.8 add /wlan/ip and /packages REST API for package install # 0.4.6 fix download dns resolve error (sometimes) # 0.4.5 add http log, change atx-agent -d into atx-agent server -d # 0.4.4 this version is gone # 0.4.3 ignore sigint to prevent atx-agent quit # 0.4.2 hot fix, close upgrade-self # 0.4.1 fix app-download time.Timer panic error, use safe-time.Timer instead. # 0.4.0 add go-daemon lib. use safe-time.Timer to prevent panic error. this will make it run longer # 0.3.6 support upload zip and unzip, fix minicap rotation error when atx-agent is killed -9 # 0.3.5 hot fix for session # 0.3.4 fix session() sometimes can not get mainActivity error # 0.3.3 /shell support timeout # 0.3.2 fix dns resolve error when network changes # 0.3.0 use github.com/codeskyblue/heartbeat library instead of websocket, add /whatsinput # 0.2.1 support occupy /minicap connection # 0.2.0 add session support # 0.1.8 fix screenshot always the same image. (BUG in 0.1.7), add /shell/stream add timeout for /shell # 0.1.7 fix dns resolve error in /install # 0.1.6 change download logic. auto fix orientation # 0.1.5 add singlefight for minicap and minitouch, proxy dial-timeout change 30 to 10 # 0.1.4 phone remote control # 0.1.2 /download support # 0.1.1 minicap buildin ================================================ FILE: uiautomator2/watcher.py ================================================ # coding: utf-8 # import inspect import logging import threading import time import typing from collections import OrderedDict from typing import List, Optional import uiautomator2 from uiautomator2.utils import inject_call from uiautomator2.xpath import PageSource, XPathEntry, XPathSelector logger = logging.getLogger(__name__) def _callback_click(el): el.click() class WatchContext: def __init__(self, d: "uiautomator2.Device", builtin: bool = False): self._d = d self._callbacks = OrderedDict() self.__xpath_list = [] self.__lock = threading.Lock() self.__trigger_time = time.time() # 这里竟然要3个变量记录状态 self.__stop = threading.Event() self.__stopped = threading.Event() # 结束时设置 self.__started = False if builtin: self.when("继续使用").click() self.when("移入管控").when("取消").click() self.when("^立即(下载|更新)").when("取消").click() self.when("同意").click() self.when("^(好的|确定)").click() self.when("继续安装").click() self.when("安装").click() self.when("Agree").click() self.when("ALLOW").click() def wait_stable(self, seconds: float = 5.0, timeout: float = 60.0): """ wait until watches not triggered Args: seconds: stable seconds timeout: raise error when wait stable timeout Raises: TimeoutError """ if not self.__started: self.start() deadline = time.time() + timeout while time.time() < deadline: with self.__lock: if time.time() - self.__trigger_time > seconds: return True time.sleep(.2) raise TimeoutError("Unstable") def when(self, xpath: str): """ 当条件满足时,支持 .when(..).when(..) 的级联模式""" self.__xpath_list.append(xpath) return self def call(self, fn: typing.Callable): """ Args: fn: support args (d: Device, el: Element) see _run_callback function for more details """ xpath_list = tuple(self.__xpath_list) self.__xpath_list = [] assert xpath_list, "when should be called before" self._callbacks[xpath_list] = fn def click(self): self.call(_callback_click) def _run(self) -> bool: logger.debug("watch check") source = self._d.dump_hierarchy() for xpaths, func in self._callbacks.items(): ok = True last_match = None for xpath in xpaths: sel: XPathSelector = self._d.xpath(xpath, source=source) if not sel.exists: ok = False break last_match = sel.get_last_match() logger.debug("match: %s", xpath) if ok: # 全部匹配 logger.debug("watchContext xpath matched: %s", xpaths) self._run_callback(func, last_match) return True return False def _run_callback(self, func, element): inject_call(func, d=self._d, el=element) self.__trigger_time = time.time() def _run_forever(self, interval: float): try: while not self.__stop.is_set(): with self.__lock: self._run() time.sleep(interval) finally: self.__stopped.set() def start(self): if self.__started: return self.__started = True self.__stop.clear() self.__stopped.clear() interval = 2.0 # 检查周期 threading.Thread(target=self._run_forever, daemon=True, args=(interval, )).start() def stop(self): self.__stop.set() self.__stopped.wait(timeout=10) self.__started = False def close(self): """ alias of stop """ self.stop() def __enter__(self): return self def __exit__(self, type, value, traceback): logger.info("context closed") self.stop() class Watcher(): def __init__(self, d: "uiautomator2.Device"): self._d = d self._watchers = [] self._watch_stop_event = threading.Event() self._watch_stopped = threading.Event() self._watching = False # func start is calling self._triggering = False @property def _xpath(self) -> XPathEntry: return self._d.xpath def _dump_hierarchy(self): return self._d.dump_hierarchy() def when(self, xpath=None): return XPathWatcher(self, xpath) def start(self, interval: float = 2.0): """ stop watcher """ if self._watching: logger.warning("already started") return self._watching = True th = threading.Thread(name="watcher", target=self._watch_forever, args=(interval, )) th.daemon = True th.start() return th def stop(self): """ stop watcher """ if not self._watching: logger.warning("watch already stopped") return if self._watch_stopped.is_set(): return self._watch_stopped.set() self._watch_stop_event.wait(timeout=10) # reset all status self._watching = False self._watch_stopped.clear() self._watch_stop_event.clear() def reset(self): """ stop watching and remove all watchers """ if self._watching: self.stop() self.remove() def running(self) -> bool: return self._watching @property def triggering(self) -> bool: return self._triggering def _watch_forever(self, interval: float): try: wait_timeout = interval while not self._watch_stopped.wait(timeout=wait_timeout): triggered = self.run() wait_timeout = min(0.5, interval) if triggered else interval finally: self._watch_stop_event.set() def run(self, source: Optional[PageSource] = None): """ run watchers Args: source: hierarchy content """ if self.triggering: # avoid to run watcher when run watcher return False try: return self._run_watchers(source=source) except Exception as e: logger.warning("_run_watchers exception: %s", e) return False def _run_watchers(self, source=None) -> bool: """ Returns: bool (watched or not) """ source = source or self._xpath.get_page_source() for h in self._watchers: last_selector = None for xpath in h['xpaths']: last_selector = self._xpath(xpath, source) if not last_selector.exists: last_selector = None break if last_selector: logger.info("XPath(hook:%s): %s", h['name'], h['xpaths']) self._triggering = True cb = h['callback'] defaults = { "selector": last_selector, "d": self._d, "source": source, } st = inspect.signature(cb) kwargs = { key: defaults[key] for key in st.parameters.keys() if key in defaults } ba = st.bind(**kwargs) ba.apply_defaults() try: cb(*ba.args, **ba.kwargs) except Exception as e: logger.warning("watchers exception: %s", e) finally: self._triggering = False return True return False def __call__(self, name: str) -> "XPathWatcher": return XPathWatcher(self, None, name) def remove(self, name=None): """ remove watcher """ if name is None: self._watchers = [] return for w in self._watchers[:]: if w['name'] == name: logger.debug("remove(%s) %s", name, w['xpaths']) self._watchers.remove(w) class XPathWatcher(): def __init__(self, parent: Watcher, xpath: str, name: str = ''): self._name = name self._parent = parent self._xpath_list: List[str] = [xpath] if xpath else [] def when(self, xpath: str = None): self._xpath_list.append(xpath) return self def call(self, func: callable): """ func accept argument, key(d, el) d=self._d, el=element """ self._parent._watchers.append({ "name": self._name, "xpaths": self._xpath_list, "callback": func, }) def click(self): def _inner_click(selector: XPathSelector): selector.get_last_match().click() self.call(_inner_click) def press(self, key): """ key (str): on of ("home", "back", "left", "right", "up", "down", "center", "search", "enter", "delete", "del", "recent", "volume_up", "menu", "volume_down", "volume_mute", "camera", "power") """ def _inner_press(d: "uiautomator2.Device"): d.press(key) self.call(_inner_press) ================================================ FILE: uiautomator2/xpath.py ================================================ # coding: utf-8 # from __future__ import absolute_import import abc import copy import enum import functools import logging import re import time from typing import Any, Callable, Dict, List, Optional, Tuple, Union from lxml import etree from PIL import Image from uiautomator2._proto import Direction from uiautomator2.abstract import AbstractXPathBasedDevice from uiautomator2.exceptions import XPathElementNotFoundError from uiautomator2.utils import deprecated, inject_call, swipe_in_bounds logger = logging.getLogger(__name__) class TimeoutException(Exception): pass class XPathError(Exception): """basic error for xpath plugin""" def safe_xmlstr(s: str) -> str: # https://www.w3.org/TR/xml/#NT-NameStartChar s = s.strip() s = re.sub('[$@#&]', '.', s) s = re.sub('\\.+', '.', s) s = re.sub('^\\.|\\.$', '', s) return s def string_quote(s: str) -> str: """quick way to quote string""" return "{!r}".format(s) def str2bytes(v: Union[str, bytes]) -> bytes: if isinstance(v, bytes): return v if isinstance(v, str): return v.encode("utf-8") raise ValueError("Invalid type", type(v), v) def is_xpath_syntax_ok(xpath_expression: str) -> bool: try: etree.XPath(xpath_expression) return True # No error means the XPath syntax is likely okay except etree.XPathSyntaxError: return False # Indicates a syntax error in the XPath expression def convert_to_camel_case(s: str) -> str: """ Convert a string from kebab-case to camelCase. Example: "hello-world" -> "helloWorld" """ parts = s.split('-') # Convert the first letter of each part to uppercase, except for the first part camel_case_str = parts[0] + ''.join(part.capitalize() for part in parts[1:]) return camel_case_str def strict_xpath(xpath: str) -> str: """make xpath to be computer recognized xpath""" orig_xpath = xpath if xpath.lstrip("(").startswith("/"): pass elif xpath.startswith("@"): xpath = "//*[@resource-id={!r}]".format(xpath[1:]) elif xpath.startswith("^"): xpath = "//*[re:match(@text, {0}) or re:match(@content-desc, {0}) or re:match(@resource-id, {0})]".format( string_quote(xpath) ) elif xpath.startswith("%") and xpath.endswith("%"): xpath = "//*[contains(@text, {0}) or contains(@content-desc, {0})]".format( string_quote(xpath[1:-1]) ) elif xpath.startswith("%"): # ends-with text = xpath[1:] xpath = "//*[{0} = substring(@text, string-length(@text) - {1} + 1) or {0} = substring(@content-desc, string-length(@text) - {1} + 1)]".format( string_quote(text), len(text) ) elif xpath.endswith("%"): # starts-with text = xpath[:-1] xpath = ( "//*[starts-with(@text, {0}) or starts-with(@content-desc, {0})]".format( string_quote(text) ) ) else: xpath = "//*[@text={0} or @content-desc={0} or @resource-id={0}]".format( string_quote(xpath) ) xpath = xpath.rstrip("/") if not is_xpath_syntax_ok(xpath): raise XPathError("Invalid xpath", orig_xpath) logger.debug("xpath %s -> %s", orig_xpath, xpath) return xpath class XPath(str): def __new__(cls, value, *args): if isinstance(value, XPath): return value xpath = strict_xpath(value) if args: return functools.reduce(lambda a, b: a.joinpath(b), args, XPath(xpath)) else: return super().__new__(cls, xpath) def __repr__(self): return f'XPath({super().__repr__()})' def __and__(self, value: 'XPath') -> 'XPathSelector': raise NotImplementedError def joinpath(self, subpath: str) -> "XPath": if not subpath.startswith('/'): subpath = '/' + subpath return XPath(self + subpath) def all(self, source: "PageSource"): return source.find_elements(self) class PageSource: def __init__(self, xml_content: str): # Remove Left-to-Right Mark, BLM, Zero-Width Space, BOM etc Invisible chars clean_xml_content = re.sub(r'[\u200B-\u200F\uFEFF]', '', xml_content) self._xml_content = clean_xml_content @staticmethod def parse(data: Union[str, "PageSource"]) -> "PageSource": if isinstance(data, str): return PageSource(data) return data @functools.cached_property def root(self) -> etree._Element: _root = etree.fromstring(str2bytes(self._xml_content)) for node in _root.xpath("//node"): node.tag = safe_xmlstr(node.attrib.pop("class", "")) or "node" return _root def find_elements(self, xpath: str) -> List["XMLElement"]: matches = self.root.xpath(xpath, namespaces={"re": "http://exslt.org/regular-expressions"}) return [XMLElement(node) for node in matches] class XPathEntry(object): def __init__(self, d: AbstractXPathBasedDevice): """ Args: d (uiautomator2 instance) """ self._d = d assert hasattr(d, "wait_timeout") # TODO: remove wait_timeout def global_set(self, key, value): valid_keys = { "timeout", } if key not in valid_keys: raise ValueError("invalid key", key) if key == "timeout": self.implicitly_wait(value) else: setattr(self, "_" + key, value) def implicitly_wait(self, timeout): """set default timeout when click""" self._d.wait_timeout = timeout @property def wait_timeout(self): return self._d.wait_timeout @property def _watcher(self): return self._d.watcher def get_page_source(self) -> PageSource: return PageSource.parse(self._d.dump_hierarchy()) def match(self, xpath, source=None): return len(self(xpath, source).all()) > 0 @deprecated(reason="use d.watcher.when(..) instead") def when(self, xquery: str): return self._watcher.when(xquery) @deprecated(reason="use d.watcher.run() instead") def run_watchers(self, source=None): self._watcher.run() @deprecated(reason="use d.watcher.start(..) instead") def watch_background(self, interval: float = 4.0): return self._watcher.start(interval) @deprecated(reason="use d.watcher.stop() instead") def watch_stop(self): """stop watch background""" self._watcher.stop() @deprecated(reason="use d.watcher.remove() instead") def watch_clear(self): self._watcher.stop() @deprecated(reason="removed") def sleep_watch(self, seconds): """run watchers when sleep""" deadline = time.time() + seconds while time.time() < deadline: self.run_watchers() left_time = max(0, deadline - time.time()) time.sleep(min(0.5, left_time)) def click(self, xpath: str, timeout: Optional[float]=None): """ Find element and perform click Args: xpath (str): xpath string timeout (float): pass pre_delay (float): pre delay wait time before click Raises: TimeoutException """ selector = DeviceXPathSelector(xpath, self) selector.click(timeout=timeout) def scroll_to( self, xpath: str, direction: Union[Direction, str] = Direction.FORWARD, max_swipes=10, ) -> Union["XMLElement", None]: """ Need more tests scroll up the whole screen until target element founded Returns: bool (found or not) """ if direction == Direction.FORWARD: direction = Direction.UP elif direction == Direction.BACKWARD: direction = Direction.DOWN elif direction == Direction.HORIZ_FORWARD: # Horizontal direction = Direction.LEFT elif direction == Direction.HORIZ_BACKWARD: direction = Direction.RIGHT else: raise ValueError("Invalid direction", direction) # FIXME(ssx): 还差一个检测是否到底的功能 assert max_swipes > 0 target = self(xpath) for i in range(max_swipes): if target.exists: self._d.swipe_ext(direction, 0.1) # 防止元素停留在边缘 return target.get_last_match() self._d.swipe_ext(direction, 0.5) return None def __call__(self, xpath: str, source: Optional[Union[str, PageSource]] = None) -> "DeviceXPathSelector": return DeviceXPathSelector(xpath, self, PageSource.parse(source) if source else None) class Operator(str, enum.Enum): AND = 'AND' OR = 'OR' class AbstractSelector(abc.ABC): @abc.abstractmethod def all(self, source: PageSource) -> List['XMLElement']: pass class XPathSelector(AbstractSelector): def __init__(self, value: Union[str, XPath, AbstractSelector]): if isinstance(value, str): self._base_xpath = XPath(value) elif isinstance(value, (XPath, AbstractSelector)): self._base_xpath = value else: raise ValueError("Invalid type", type(value), value) self._operator: Optional[Operator] = None self._next_xpath: Optional[AbstractSelector] = None def copy(self): """copy self""" return copy.copy(self) @classmethod def create(cls, value: Union[str, 'XPathSelector']) -> 'XPathSelector': if isinstance(value, XPathSelector): return value.copy() elif isinstance(value, str): return cls(XPath(value)) else: raise ValueError('Invalid value', value) def __repr__(self): if self._operator: return f'#({repr(self._base_xpath)} {self._operator.value} {repr(self._next_xpath)})' else: return f'#({repr(self._base_xpath)})' def __and__(self, value) -> 'XPathSelector': s = XPathSelector(self) s._next_xpath = XPathSelector.create(value) s._operator = Operator.AND return s def __or__(self, value) -> 'XPathSelector': s = XPathSelector(self) s._next_xpath = XPathSelector.create(value) s._operator = Operator.OR return s @deprecated(reason="use and_ or & instead") def xpath(self, _xpath: Union[list, tuple, str]) -> 'XPathSelector': """ add xpath to condition list the element should match all conditions """ if isinstance(_xpath, (list, tuple)): return functools.reduce(lambda a, b: a & b, _xpath, self) else: return self & _xpath def child(self, _xpath: str) -> "XPathSelector": """ add child xpath """ if self._operator or not isinstance(self._base_xpath, XPath): raise XPathError("can't use child when base is not XPath or operator is set") new = self.copy() new._base_xpath = self._base_xpath.joinpath(_xpath) return new def all(self, source: PageSource) -> List["XMLElement"]: """find all matched elements""" elements = self._base_xpath.all(source) # AND OR if self._next_xpath and self._operator: next_els = self._next_xpath.all(source) if self._operator == Operator.AND: elements = list(set(elements) & set(next_els)) elif self._operator == Operator.OR: elements = list(set(elements) | set(next_els)) else: raise ValueError("Invalid operator", self._operator) return elements class DeviceXPathSelector(XPathSelector): def __init__(self, xpath: Union[str, AbstractSelector], parent: XPathEntry, source: Optional[PageSource] = None): super().__init__(xpath) self._parent = parent self._source = source self._last_source: Optional[PageSource] = None self._fallback: Optional[Callable] = None def from_parent(self, p: XPathSelector): dp = DeviceXPathSelector(p._base_xpath, self._parent, self._source) dp._operator = p._operator dp._next_xpath = p._next_xpath return dp def __and__(self, value) -> 'DeviceXPathSelector': s = super().__and__(value) return self.from_parent(s) def __or__(self, value) -> 'DeviceXPathSelector': s = super().__or__(value) return self.from_parent(s) def fallback(self, func: Optional[Callable[..., bool]] = None, *args, **kwargs): """ callback on failure """ if not callable(func): raise ValueError('func should be "click" or callable function') assert callable(func) new = self.copy() new._fallback = func return new @property def _global_timeout(self) -> float: if hasattr(self._parent, "wait_timeout") and isinstance(self._parent.wait_timeout, (int, float)): return self._parent.wait_timeout return 20.0 def _get_page_source(self) -> PageSource: if self._source: return self._source if not self._parent: raise XPathError("self._parent is not set") return self._parent.get_page_source() def all(self, source: Optional[PageSource] = None) -> List["DeviceXMLElement"]: """find all matched elements""" if not source: source = self._get_page_source() self._last_source = source elements = super().all(source) return [DeviceXMLElement(el, self._parent) for el in elements] @property def exists(self) -> bool: return len(self.all()) > 0 def get(self, timeout=None): """ Get first matched element Args: timeout (float): max seconds to wait Returns: XMLElement Raises: XPathElementNotFoundError """ if not self.wait(timeout or self._global_timeout): raise XPathElementNotFoundError(self) return self.get_last_match() def get_last_match(self) -> "DeviceXMLElement": return self.all(self._last_source)[0] def get_text(self) -> Optional[str]: """ get element text Returns: string of node text Raises: XPathElementNotFoundError """ return self.get().text def set_text(self, text: str): el = self.get() el.click() # focus input-area self._parent._d.clear_text() # type: ignore self._parent._d.send_keys(text) def wait(self, timeout=None) -> bool: """ wait until element found """ deadline = time.time() + (timeout or self._global_timeout) while True: if self.exists: return True if time.time() > deadline: return False time.sleep(0.2) def match(self) -> Optional["DeviceXMLElement"]: """ Returns: None or matched DeviceXMLElement """ if self.exists: return self.get_last_match() def wait_gone(self, timeout=None) -> bool: """ Args: timeout (float): seconds Returns: True if gone else False """ deadline = time.time() + (timeout or self._global_timeout) while time.time() < deadline: if not self.exists: return True time.sleep(0.2) return False def click_nowait(self): x, y = self.all()[0].center() logger.info("click %d, %d", x, y) self._parent._d.click(x, y) def click(self, timeout=None): """find element and perform click""" try: el = self.get(timeout=timeout) el.click() except XPathElementNotFoundError: if not self._fallback: raise logger.info("element not found, run fallback") return inject_call(self._fallback, d=self._d) def click_exists(self, timeout=None) -> bool: """return if clicked""" try: el = self.get(timeout=timeout) el.click() return True except XPathElementNotFoundError: return False def long_click(self): """find element and perform long click""" self.get().long_click() def screenshot(self) -> Image.Image: """take element screenshot""" el = self.get() return el.screenshot() def __getattr__(self, key: str): """ In IPython console, attr:_ipython_canary_method_should_not_exist_ will be called So here ignore all attr startswith _ """ if key.startswith("_"): raise AttributeError("Invalid attr", key) if not hasattr(DeviceXMLElement, key): raise AttributeError("Invalid attr", key) el = self.get() return getattr(el, key) class XMLElement(object): def __init__(self, elem: etree._Element): """ Args: elem: lxml node d: uiautomator2 instance """ self.elem = elem def __hash__(self): return hash(self.elem) def __eq__(self, value): return self.__hash__() == hash(value) def __repr__(self): x, y = self.center() return "".format( tag=self.elem.tag, x=x, y=y ) def get_xpath(self, strip_index: bool = False): """get element full xpath""" root = self.elem.getroottree() path = root.getpath(self.elem) if strip_index: path = re.sub(r"\[\d+\]", "", path) # remove indexes return path def center(self): """ Returns: (x, y) """ return self.offset(0.5, 0.5) def offset(self, px: float = 0.0, py: float = 0.0): """ Offset from left_top Args: px (float): percent of width py (float): percent of height Example: offset(0.5, 0.5) means center """ x, y, width, height = self.rect return x + int(width * px), y + int(height * py) def parent(self, xpath: Optional[str] = None) -> Union["XMLElement", None]: """ Returns parent element """ if xpath is None: return XMLElement(self.elem.getparent()) root = self.elem.getroottree() e = self.elem els = [] while e is not None and e != root: els.append(e) e = e.getparent() xpath = strict_xpath(xpath) matches = root.xpath( xpath, namespaces={"re": "http://exslt.org/regular-expressions"} ) all_paths = [root.getpath(m) for m in matches] for e in reversed(els): if root.getpath(e) in all_paths: return XMLElement(e) @functools.cached_property def bounds(self) -> Tuple[int, int, int, int]: """ Returns: tuple of (left, top, right, bottom) """ bounds = self.elem.attrib.get("bounds") if not bounds: return (0, 0, 0, 0) lx, ly, rx, ry = map(int, re.findall(r"\d+", bounds)) return (lx, ly, rx, ry) @property def rect(self) -> Tuple[int, int, int, int]: """ Returns: (left_top_x, left_top_y, width, height) """ lx, ly, rx, ry = self.bounds return lx, ly, rx - lx, ry - ly @property def text(self): return self.elem.attrib.get("text") @property def attrib(self) -> Dict[str, str]: return dict(self.elem.attrib) @property def info(self) -> Dict[str, Any]: ret = {} for k, v in dict(self.attrib).items(): if k in ("bounds", "class", "package", "content-desc"): continue if k in ("checkable", "checked", "clickable", "enabled", "focusable", "focused", "scrollable", "long-clickable", "password", "selected", "visible-to-user"): ret[convert_to_camel_case(k)] = v == "true" elif k == "index": ret[k] = int(v) else: ret[convert_to_camel_case(k)] = v ret["childCount"] = len(self.elem.getchildren()) ret["className"] = self.elem.tag lx, ly, rx, ry = self.bounds ret["bounds"] = {"left": lx, "top": ly, "right": rx, "bottom": ry} # 名字命名的有点奇怪,为了兼容性暂时保留 ret["packageName"] = self.attrib.get("package") ret["contentDescription"] = self.attrib.get("content-desc") ret["resourceName"] = self.attrib.get("resource-id") return ret class DeviceXMLElement(XMLElement): def __init__(self, el: XMLElement, parent: XPathEntry): super().__init__(el.elem) self._parent = parent def click(self): """ click element, 100ms between down and up """ x, y = self.center() self._parent._d.click(x, y) def long_click(self): """ Sometime long click is needed, 400ms between down and up """ x, y = self.center() self._parent._d.long_click(x, y) def screenshot(self): """ Take screenshot of element """ im = self._parent._d.screenshot() return im.crop(self.bounds) def swipe(self, direction: Union[Direction, str], scale: float = 0.6): """ Args: direction: one of ["left", "right", "up", "down"] scale: percent of swipe, range (0, 1.0) Raises: AssertionError, ValueError """ return swipe_in_bounds(self._parent._d, self.bounds, direction, scale) def scroll(self, direction: Union[Direction, str] = Direction.FORWARD) -> bool: """ Args: direction: Direction eg: Direction.FORWARD Returns: bool: if can be scroll again """ if direction == "forward": direction = Direction.FORWARD elif direction == "backward": direction = Direction.BACKWARD els = set(self._parent("//*").all()) self.swipe(direction, scale=0.6) # check if there is more element new_elements = set(self._parent("//*").all()) - els ppath = self.get_xpath() + "/" # limit to child nodes els = [el for el in new_elements if el.get_xpath().startswith(ppath)] return len(els) > 0 def scroll_to( self, xpath: str, direction: Direction = Direction.FORWARD, max_swipes: int = 10 ) -> Union["XMLElement", None]: assert max_swipes > 0 target = self._parent(xpath) for i in range(max_swipes): if target.exists: return target.get_last_match() if not self.scroll(direction): break return None def percent_bounds(self, wsize: Optional[tuple] = None): """ Args: wsize (tuple(int, int)): window size Returns: list of 4 float, eg: 0.1, 0.2, 0.5, 0.8 """ lx, ly, rx, ry = self.bounds ww, wh = wsize or self._parent._d.window_size() return (lx / ww, ly / wh, rx / ww, ry / wh) def percent_size(self): """Returns: (float, float): eg, (0.5, 0.5) means 50%, 50% """ ww, wh = self._parent._d.window_size() _, _, w, h = self.rect return (w / ww, h / wh) ================================================ FILE: uibox/LICENSE ================================================ ================================================ FILE: uibox/Makefile ================================================ build: @go work use . @GOOS=linux GOARCH=arm64 go build -o uibox main.go # Define color codes GREEN := \033[32m YELLOW := \033[33m RESET := \033[0m ADB_ARGS := "-d" define echo @echo ">>> $(GREEN)$(1)$(RESET)" endef test: build @adb $(ADB_ARGS) shell getprop ro.product.cpu.abilist @$(call echo,Pushing uibox to /data/local/tmp/uibox) @adb $(ADB_ARGS) push uibox /data/local/tmp/uibox @adb $(ADB_ARGS) shell chmod 777 /data/local/tmp/uibox @$(call echo,Running uibox) @adb $(ADB_ARGS) shell /data/local/tmp/uibox -h ================================================ FILE: uibox/README.md ================================================ # uibox Add new command ``` cobra-init add nohup ``` ================================================ FILE: uibox/cmd/httpcheck.go ================================================ /* Copyright © 2024 NAME HERE */ package cmd import ( "context" "crypto/tls" "fmt" "net" "net/http" "strings" "sync" "time" "github.com/spf13/cobra" ) // httpcheckCmd represents the httpcheck command var httpcheckCmd = &cobra.Command{ Use: "httpcheck", Short: "check if network is available by sending a http request", Run: httpcheckRun, } func init() { rootCmd.AddCommand(httpcheckCmd) httpcheckCmd.Flags().StringP("dns", "d", "114.114.114.114", "DNS resolver") } func httpcheckRun(_ *cobra.Command, args []string) { fmt.Println("httpcheck called") doHttpCheck() } // Function to check a single site with specific DNS and timeout settings func checkSite(url string, wg *sync.WaitGroup, resultChan chan<- bool, dnsResolver string, httpTimeout time.Duration) { defer wg.Done() // Append default DNS port if not specified if !strings.Contains(dnsResolver, ":") { dnsResolver += ":53" } // Custom dialer with specific DNS resolver dialer := &net.Dialer{ Timeout: httpTimeout, Resolver: &net.Resolver{ PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) { d := net.Dialer{ Timeout: httpTimeout, } return d.DialContext(ctx, "udp", dnsResolver) }, }, } // HTTP client using the custom transport with dialer client := http.Client{ Timeout: httpTimeout, Transport: &http.Transport{ DialContext: dialer.DialContext, TLSClientConfig: &tls.Config{ InsecureSkipVerify: true, // Skip SSL certificate verification }, }, } // Perform HTTP GET request resp, err := client.Get(url) if err != nil { fmt.Println("Error checking:", url, err) resultChan <- false return } defer resp.Body.Close() // Check HTTP status if resp.StatusCode == http.StatusOK { fmt.Println(url, "is up.") resultChan <- true } else { fmt.Println(url, "status code:", resp.StatusCode) resultChan <- false } } func doHttpCheck() { var wg sync.WaitGroup resultChan := make(chan bool, 3) // Buffer for 3 results // Command-line flags var dnsResolver string = "114.114.114.114" var timeoutSec int = 3 var urls = []string{"https://taobao.com", "https://qq.com", "https://baidu.com", "https://www.example.com"} // Convert timeout to time.Duration httpTimeout := time.Duration(timeoutSec) * time.Second // URLs to check for _, url := range urls { wg.Add(1) go checkSite(url, &wg, resultChan, dnsResolver, httpTimeout) } go func() { wg.Wait() close(resultChan) }() // Evaluate results networkOK := false for result := range resultChan { if result { networkOK = true break } } if networkOK { fmt.Println("network=true") } else { fmt.Println("network=false") } } ================================================ FILE: uibox/cmd/nohup.go ================================================ /* Copyright © 2024 NAME HERE */ package cmd import ( "fmt" "os" "os/exec" "syscall" "github.com/spf13/cobra" ) // nohupCmd represents the nohup command var nohupCmd = &cobra.Command{ Use: "nohup", Short: "invoke a utility immune to hangups", Long: `The nohup utility invokes utility with its arguments and at this time sets the signal SIGHUP to be ignored. If the standard output is a terminal, the standard output is appended to the file nohup.out in the current directory. If standard error is a terminal, it is directed to the same place as the standard output.`, DisableFlagParsing: true, Run: nohupRun, } func init() { rootCmd.AddCommand(nohupCmd) nohupCmd.SetUsageTemplate(`Usage: nohup COMMAND [ARG]...`) } func nohupRun(_ *cobra.Command, args []string) { // Ignore SIGHUP signal cmd := exec.Command(args[0], args[1:]...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // Start the command if err := cmd.Start(); err != nil { fmt.Fprintf(os.Stderr, "Error starting command: %v\n", err) os.Exit(1) } // Wait for the command to finish if err := cmd.Wait(); err != nil { fmt.Fprintf(os.Stderr, "Error waiting for command: %v\n", err) os.Exit(1) } } ================================================ FILE: uibox/cmd/root.go ================================================ /* Copyright © 2024 NAME HERE */ package cmd import ( "os" "github.com/spf13/cobra" ) // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "uibox", Short: "A brief description of your application", Long: `A longer description that spans multiple lines and likely contains examples and usage of using your application. For example: Cobra is a CLI library for Go that empowers applications. This application is a tool to generate the needed files to quickly create a Cobra application.`, // Uncomment the following line if your bare application // has an action associated with it: // Run: func(cmd *cobra.Command, args []string) { }, } // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { err := rootCmd.Execute() if err != nil { os.Exit(1) } } func init() { // Here you will define your flags and configuration settings. // Cobra supports persistent flags, which, if defined here, // will be global for your application. // rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.uibox.yaml)") // Cobra also supports local flags, which will only run // when this action is called directly. rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") } ================================================ FILE: uibox/go.mod ================================================ module github.com/openatx/uiautomator2/uibox go 1.22.1 require github.com/spf13/cobra v1.8.0 require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect ) ================================================ FILE: uibox/go.sum ================================================ github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: uibox/go.work ================================================ go 1.22.1 use . ================================================ FILE: uibox/main.go ================================================ /* Copyright © 2024 NAME HERE */ package main import "github.com/openatx/uiautomator2/uibox/cmd" func main() { cmd.Execute() }