From c25a3e512e5447b051a06aabf43f115ee21f0e7d Mon Sep 17 00:00:00 2001 From: codeskyblue Date: Wed, 17 Jul 2024 00:07:19 +0800 Subject: [PATCH] use u2.jar instead of test apk (#1004) * use u2.jar instead of test apk * update toast api, use d.last_toast to get toast, d.clear_toast() to reset it * add more unittests * fix multi thread when use connect --- .coveragerc | 8 +- Makefile | 5 + QUICK_REFERENCE.md | 48 +++++- README.md | 25 +-- .../_archived => _archived}/aircv/README.md | 0 .../_archived => _archived}/aircv/__init__.py | 0 {uiautomator2/_archived => _archived}/init.py | 0 .../_archived => _archived}/messagebox.py | 0 .../_archived => _archived}/ocr/README.md | 0 .../_archived => _archived}/ocr/__init__.py | 0 .../_archived => _archived}/ocr/baiduOCR.py | 0 .../_archived => _archived}/webview.py | 0 .../_archived => _archived}/widget.py | 0 demo_tests/conftest.py | 19 ++ demo_tests/test_app.py | 57 ++++++ demo_tests/test_core.py | 41 +++++ demo_tests/test_device.py | 131 ++++++++++++++ demo_tests/test_input.py | 39 +++++ demo_tests/test_selector.py | 89 ++++++++++ demo_tests/test_watcher.py | 4 + docs/2to3.md | 10 +- pyproject.toml | 3 +- tests/test_settings.py | 18 ++ {mobile_tests => tests}/test_utils.py | 29 +++- uiautomator2/__init__.py | 145 ++++++++-------- uiautomator2/__main__.py | 4 +- uiautomator2/_input.py | 71 ++++++-- uiautomator2/_selector.py | 14 +- uiautomator2/assets/.gitignore | 3 +- uiautomator2/assets/sync.sh | 40 +++-- uiautomator2/core.py | 163 +++++++----------- uiautomator2/exceptions.py | 34 +++- uiautomator2/settings.py | 7 +- uiautomator2/swipe.py | 4 +- uiautomator2/utils.py | 22 +-- uiautomator2/xpath.py | 2 +- 36 files changed, 757 insertions(+), 278 deletions(-) rename {uiautomator2/_archived => _archived}/aircv/README.md (100%) rename {uiautomator2/_archived => _archived}/aircv/__init__.py (100%) rename {uiautomator2/_archived => _archived}/init.py (100%) rename {uiautomator2/_archived => _archived}/messagebox.py (100%) rename {uiautomator2/_archived => _archived}/ocr/README.md (100%) rename {uiautomator2/_archived => _archived}/ocr/__init__.py (100%) rename {uiautomator2/_archived => _archived}/ocr/baiduOCR.py (100%) rename {uiautomator2/_archived => _archived}/webview.py (100%) rename {uiautomator2/_archived => _archived}/widget.py (100%) create mode 100644 demo_tests/conftest.py create mode 100644 demo_tests/test_app.py create mode 100644 demo_tests/test_core.py create mode 100644 demo_tests/test_device.py create mode 100644 demo_tests/test_input.py create mode 100644 demo_tests/test_selector.py create mode 100644 demo_tests/test_watcher.py create mode 100644 tests/test_settings.py rename {mobile_tests => tests}/test_utils.py (70%) diff --git a/.coveragerc b/.coveragerc index 2db95efa..dc010c19 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,8 +2,9 @@ branch = True omit = - "tests/*" - "docs/*" + /tests/** + /docs/* + /*_tests/** [report] ; Regexes for lines to exclude from consideration @@ -23,4 +24,7 @@ exclude_also = ; Don't complain about abstract methods, they aren't run: @(abc\.)?abstractmethod + except adbutils.AdbError + @deprecated + ignore_errors = True diff --git a/Makefile b/Makefile index 6e87c878..b3e3e4c8 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,11 @@ format: 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 \ diff --git a/QUICK_REFERENCE.md b/QUICK_REFERENCE.md index fe0a0741..955560f3 100644 --- a/QUICK_REFERENCE.md +++ b/QUICK_REFERENCE.md @@ -6,6 +6,23 @@ 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) # 启动应用前停止应用 @@ -18,21 +35,51 @@ 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) # 修改默认等待时间 @@ -40,7 +87,6 @@ d.xpath("立即开户").wait(timeout=10) # 修改默认等待时间 # 常用配置 d.settings['wait_timeout'] = 20 # 控件查找默认等待时间(默认20s) -# xpath操作 d.xpath("立即开户").click() # 包含查找等待+点击操作,匹配text或者description等于立即开户的按钮 d.xpath("//*[@text='私人FM']/../android.widget.ImageView").click() diff --git a/README.md b/README.md index 49e8853b..0936f392 100644 --- a/README.md +++ b/README.md @@ -1292,30 +1292,13 @@ print(d.current_ime()) # 获取当前输入法ID > 更多参考: [IME_ACTION_CODE](https://developer.android.com/reference/android/view/inputmethod/EditorInfo) -### Toast (2.2版本之后有添加回来) -Show Toast (好像有点bug) - +### Toast ```python -d.toast.show("Hello world") -d.toast.show("Hello world", 1.0) # show for 1.0s, default 1.0s +print(d.last_toast) # get last toast, if not toast return None +d.clear_toast() ``` -Get Toast - -```python -# [Args] -# 5.0: max wait timeout. Default 10.0 -# 10.0: cache time. return cache toast if already toast already show up in recent 10 seconds. Default 10.0 (Maybe change in the furture) -# "default message": return if no toast finally get. Default None -d.toast.get_message(5.0, 10.0, "default message") - -# common usage -assert "Short message" in d.toast.get_message(5.0, default="") - -# clear cached toast -d.toast.reset() -# Now d.toast.get_message(0) is None -``` +> Fixed in version 3.2.0 ### XPath Java uiautoamtor中默认是不支持xpath的,所以这里属于扩展的一个功能。速度不是这么的快。 diff --git a/uiautomator2/_archived/aircv/README.md b/_archived/aircv/README.md similarity index 100% rename from uiautomator2/_archived/aircv/README.md rename to _archived/aircv/README.md diff --git a/uiautomator2/_archived/aircv/__init__.py b/_archived/aircv/__init__.py similarity index 100% rename from uiautomator2/_archived/aircv/__init__.py rename to _archived/aircv/__init__.py diff --git a/uiautomator2/_archived/init.py b/_archived/init.py similarity index 100% rename from uiautomator2/_archived/init.py rename to _archived/init.py diff --git a/uiautomator2/_archived/messagebox.py b/_archived/messagebox.py similarity index 100% rename from uiautomator2/_archived/messagebox.py rename to _archived/messagebox.py diff --git a/uiautomator2/_archived/ocr/README.md b/_archived/ocr/README.md similarity index 100% rename from uiautomator2/_archived/ocr/README.md rename to _archived/ocr/README.md diff --git a/uiautomator2/_archived/ocr/__init__.py b/_archived/ocr/__init__.py similarity index 100% rename from uiautomator2/_archived/ocr/__init__.py rename to _archived/ocr/__init__.py diff --git a/uiautomator2/_archived/ocr/baiduOCR.py b/_archived/ocr/baiduOCR.py similarity index 100% rename from uiautomator2/_archived/ocr/baiduOCR.py rename to _archived/ocr/baiduOCR.py diff --git a/uiautomator2/_archived/webview.py b/_archived/webview.py similarity index 100% rename from uiautomator2/_archived/webview.py rename to _archived/webview.py diff --git a/uiautomator2/_archived/widget.py b/_archived/widget.py similarity index 100% rename from uiautomator2/_archived/widget.py rename to _archived/widget.py diff --git a/demo_tests/conftest.py b/demo_tests/conftest.py new file mode 100644 index 00000000..8fc92cfd --- /dev/null +++ b/demo_tests/conftest.py @@ -0,0 +1,19 @@ +# 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 \ No newline at end of file diff --git a/demo_tests/test_app.py b/demo_tests/test_app.py new file mode 100644 index 00000000..8604543b --- /dev/null +++ b/demo_tests/test_app.py @@ -0,0 +1,57 @@ +# 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 + \ No newline at end of file diff --git a/demo_tests/test_core.py b/demo_tests/test_core.py new file mode 100644 index 00000000..784b8500 --- /dev/null +++ b/demo_tests/test_core.py @@ -0,0 +1,41 @@ +# 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 + \ No newline at end of file diff --git a/demo_tests/test_device.py b/demo_tests/test_device.py new file mode 100644 index 00000000..c51d91e9 --- /dev/null +++ b/demo_tests/test_device.py @@ -0,0 +1,131 @@ +# coding: utf-8 +# author: codeskyblue + +from pathlib import Path +import random +import pytest +import uiautomator2 as u2 +from PIL import Image + + +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 \ No newline at end of file diff --git a/demo_tests/test_watcher.py b/demo_tests/test_watcher.py new file mode 100644 index 00000000..1ab84180 --- /dev/null +++ b/demo_tests/test_watcher.py @@ -0,0 +1,4 @@ +# coding: utf-8 +# author: codeskyblue + +# TODO \ No newline at end of file diff --git a/docs/2to3.md b/docs/2to3.md index 45ce3265..beafdb47 100644 --- a/docs/2to3.md +++ b/docs/2to3.md @@ -56,6 +56,7 @@ - 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 @@ -179,4 +180,11 @@ print(d.device_info) ### 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" \ No newline at end of file +- 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() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 0bbcba6b..334d854a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "uiautomator2" -version = "0.0.0" +version = "3.2.0" description = "uiautomator for android device" homepage = "https://github.com/openatx/uiautomator2" authors = ["codeskyblue "] @@ -21,6 +21,7 @@ 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" diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 00000000..cdb98562 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,18 @@ +# 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 \ No newline at end of file diff --git a/mobile_tests/test_utils.py b/tests/test_utils.py similarity index 70% rename from mobile_tests/test_utils.py rename to tests/test_utils.py index 7569b2f1..691621c8 100644 --- a/mobile_tests/test_utils.py +++ b/tests/test_utils.py @@ -5,6 +5,7 @@ import time import pytest +from PIL import Image from uiautomator2 import utils @@ -13,7 +14,8 @@ def test_list2cmdline(): testdata = [ [("echo", "hello"), "echo hello"], [("echo", "hello&world"), "echo 'hello&world'"], - [("What's", "your", "name?"), """'What'"'"'s' your 'name?'"""] + [("What's", "your", "name?"), """'What'"'"'s' your 'name?'"""], + ["echo hello", "echo hello"], ] for args, expect in testdata: cmdline = utils.list2cmdline(args) @@ -62,3 +64,28 @@ def test_is_version_compatiable(): 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() \ No newline at end of file diff --git a/uiautomator2/__init__.py b/uiautomator2/__init__.py index bad121c9..5d021ccf 100644 --- a/uiautomator2/__init__.py +++ b/uiautomator2/__init__.py @@ -10,11 +10,10 @@ import logging import os import re -import string import time import warnings from functools import cached_property -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Tuple, Union import adbutils from lxml import etree @@ -27,7 +26,7 @@ from uiautomator2._proto import HTTP_TIMEOUT, SCROLL_STEPS, Direction from uiautomator2._selector import Selector, UiObject from uiautomator2._input import InputMethodMixIn -from uiautomator2.exceptions import AdbShellError, BaseException, ConnectError, DeviceError, HierarchyEmptyError, SessionBrokenError +from uiautomator2.exceptions import * from uiautomator2.settings import Settings from uiautomator2.swipe import SwipeExt from uiautomator2.utils import image_convert, list2cmdline, deprecated @@ -40,7 +39,7 @@ logger = logging.getLogger(__name__) def enable_pretty_logging(level=logging.DEBUG): - if not logger.handlers: + 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') @@ -197,10 +196,6 @@ def reset_uiautomator(self): - start uiautomator keeper(am instrument -w ...) - wait until uiautomator service is ready """ - # https://developer.android.google.cn/training/monitoring-device-state/doze-standby - # 让uiautomator进程不进入doze模式 - # help: dumpsys deviceidle help - self.shell("dumpsys deviceidle whitelist +com.github.uiautomator; dumpsys deviceidle whitelist +com.github.uiautomator.test") self.stop_uiautomator() self.start_uiautomator() @@ -247,7 +242,7 @@ def screenshot(self, filename: Optional[str] = None, format="pillow", display_id Args: filename (str): saved filename, if filename is set then return None - format (str): used when filename is empty. one of ["pillow", "opencv", "raw"] + format (str): used when filename is empty. one of ["pillow", "opencv"] display_id (int): use specific display if device has multiple screen Examples: @@ -281,13 +276,14 @@ def dump_hierarchy(self, compressed=False, pretty=False, max_depth: int = None) """ try: content = self._do_dump_hierarchy(compressed, max_depth) - except HierarchyEmptyError: + 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=str) + 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) @@ -435,7 +431,7 @@ def swipe(self, fx, fy, tx, ty, duration: Optional[float] = None, steps: Optiona 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") + warnings.warn("duration and steps can not be set at the same time, use steps", UserWarning) duration = None if duration: steps = int(duration * 200) @@ -449,7 +445,7 @@ def swipe(self, fx, fy, tx, ty, duration: Optional[float] = None, steps: Optiona with self._operation_delay("swipe"): return self.jsonrpc.swipe(fx, fy, tx, ty, steps) - def swipe_points(self, points, duration: float = 0.5): + 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]] @@ -464,7 +460,8 @@ def swipe_points(self, points, duration: float = 0.5): x, y = rel2abs(p[0], p[1]) ppoints.append(x) ppoints.append(y) - steps = int(duration * 200) + # 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): @@ -513,7 +510,7 @@ def screen_off(self): self.jsonrpc.sleep() @property - def orientation(self): + def orientation(self) -> str: ''' orienting the devie to left/right or natural. left/l: rotation=90 , displayRotation=1 @@ -523,7 +520,8 @@ def orientation(self): ''' return self.__orientation[self.info["displayRotation"]][1] - def set_orientation(self, value): + @orientation.setter + def orientation(self, value: str): '''setter of orientation property.''' for values in self.__orientation: if value in values: @@ -544,6 +542,13 @@ def last_traversed_text(self): 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() @@ -559,9 +564,8 @@ def exists(self, **kwargs): return self(**kwargs).exists @property - def clipboard(self) -> str: - return super().clipboard - # return self.jsonrpc.getClipboard() # FIXME(ssx): bug + def clipboard(self) -> Optional[str]: + return self.jsonrpc.getClipboard() @clipboard.setter def clipboard(self, text: str): @@ -599,51 +603,8 @@ def serial(self) -> str: if self._serial: return self._serial return self.shell(['getprop', 'ro.serialno']).output.strip() - - def show_float_window(self, show=True): - """ 显示悬浮窗,提高uiautomator运行的稳定性 """ - arg = str(show).lower() - self.shell([ - "am", "start", "-n", "com.github.uiautomator/.ToastActivity", "-e", - "showFloatWindow", arg - ]) - - @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: return immediately if toast showed in recent $cache_timeout - 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(cache_timeout * 1000) - 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 __call__(self, **kwargs): + def __call__(self, **kwargs) -> 'UiObject': return UiObject(self, Selector(**kwargs)) @@ -887,11 +848,11 @@ def app_info(self, package_name: str) -> Dict[str, Any]: } Raises: - UiaError + AppNotFoundError """ info = self.adb_device.app_info(package_name) if not info: - raise BaseException("App not installed") + raise AppNotFoundError("App not installed", package_name) return { "versionName": info.version_name, "versionCode": info.version_code, @@ -942,7 +903,7 @@ def app_auto_grant_permissions(self, package_name: str): logger.info(f'auto grant permission {permission}') -class _DeprecatedMixIn: +class _DeprecatedMixIn: # pragma: no cover @property def wait_timeout(self): # wait element timeout return self.settings['wait_timeout'] @@ -960,6 +921,16 @@ def click_post_delay(self): 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']: + self.shell("input keyevent WAKEUP") + 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 @@ -968,13 +939,45 @@ def make_toast(self, text, duration=1.0): duration (float): seconds of display """ return self.jsonrpc.makeToast(text, duration * 1000) + + @property + def toast(self): + obj = self - def unlock(self): - """ unlock screen with swipe from left-bottom to right-top """ - if not self.info['screenOn']: - self.shell("input keyevent WAKEUP") - self.swipe(0.1, 0.9, 0.9, 0.1) + 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: diff --git a/uiautomator2/__main__.py b/uiautomator2/__main__.py index 3d9f8956..9f98c510 100644 --- a/uiautomator2/__main__.py +++ b/uiautomator2/__main__.py @@ -23,12 +23,12 @@ def cmd_init(args): if serial: d = u2.connect(serial) logger.debug("install apk to %s", d.serial) - d._setup_apks() + 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_apks() + d._setup_jar() def cmd_purge(args): diff --git a/uiautomator2/_input.py b/uiautomator2/_input.py index b7ef2859..f867067e 100644 --- a/uiautomator2/_input.py +++ b/uiautomator2/_input.py @@ -6,17 +6,22 @@ import base64 from dataclasses import dataclass +import logging +from pathlib import Path import re -from typing import Dict, Optional, Union +import time +from typing import Dict, List, Optional, Union import warnings from retry import retry from uiautomator2.abstract import AbstractShell -from uiautomator2.exceptions import AdbBroadcastError, DeviceError +from uiautomator2.exceptions import AdbBroadcastError, DeviceError, InputIMEError from uiautomator2.utils import deprecated +logger = logging.getLogger(__name__) + @dataclass class BroadcastResult: code: Optional[int] @@ -29,25 +34,45 @@ class BroadcastResult: 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 clipboard(self): - result = self._broadcast("ADB_KEYBOARD_GET_CLIPBOARD") - if result.code == BORADCAST_RESULT_OK: - return base64.b64decode(result.data).decode('utf-8') - return self.jsonrpc.getClipboard() + def __ime_id(self) -> str: + return 'com.github.uiautomator/.AdbKeyboard' def set_input_ime(self, enable: bool = True): """ Enable of Disable InputIME """ - ime_id = 'com.github.uiautomator/.AdbKeyboard' if not enable: - self.shell(['ime', 'disable', ime_id]) + self.shell(['ime', 'disable', self.__ime_id]) return - - if self.current_ime() == ime_id: + if self.current_ime() == self.__ime_id: return - self.shell(['ime', 'enable', ime_id]) - self.shell(['ime', 'set', ime_id]) - self.shell(['settings', 'put', 'secure', 'default_input_method', ime_id]) + # 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]) + + def __setup_ime(self): + logger.debug("installing AdbKeyboard ime") + assets_dir = Path(__file__).parent / "assets" + ime_apk_path = assets_dir / 'app-uiautomator.apk' + 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+ @@ -77,11 +102,19 @@ def send_keys(self, text: str, clear: bool = False): text (str): text to set clear (bool): clear before set text """ + if clear: + self.clear_text() + if re.match(r'^[-+*\/_a-zA-Z0-9 ]+$', text): + self.shell(['input', 'text', text.replace(' ', '%s')]) + else: + self.__send_keys_with_ime(text) + + def __send_keys_with_ime(self, text: str): try: self.set_input_ime() btext = text.encode('utf-8') base64text = base64.b64encode(btext).decode() - cmd = "ADB_KEYBOARD_SET_TEXT" if clear else "ADB_KEYBOARD_INPUT_TEXT" + cmd = "ADB_KEYBOARD_INPUT_TEXT" self._must_broadcast(cmd, {"text": base64text}) return True except AdbBroadcastError: @@ -89,9 +122,7 @@ def send_keys(self, text: str, clear: bool = False): "set FastInputIME failed. use \"d(focused=True).set_text instead\"", Warning) return self(focused=True).set_text(text) - # warnings.warn("set FastInputIME failed. use \"adb shell input text\" instead", Warning) - # self.shell(["input", "text", text.replace(" ", "%s")]) - + def send_action(self, code: Union[str, int] = None): """ Simulate input method edito code @@ -149,6 +180,10 @@ def current_ime(self) -> str: # shown = "mInputShown=true" in dim # return (method_id, shown) + def __get_ime_list(self) -> List[str]: + ret = self.shell(['ime', 'list', '-s', '-a']) + return ret.output.strip().splitlines(keepends=False) + @deprecated(reason="use set_input_ime instead") def set_fastinput_ime(self, enable: bool = True): return self.set_input_ime(enable) diff --git a/uiautomator2/_selector.py b/uiautomator2/_selector.py index 3d75b2a6..6f4dd2f2 100644 --- a/uiautomator2/_selector.py +++ b/uiautomator2/_selector.py @@ -1,14 +1,13 @@ import logging import time import warnings -from typing import Optional +from typing import Optional, Tuple -import requests from PIL import Image from retry import retry from uiautomator2._proto import SCROLL_STEPS -from uiautomator2.exceptions import UiObjectNotFoundError +from uiautomator2.exceptions import HTTPError, UiObjectNotFoundError from uiautomator2.utils import Exists, intersect @@ -124,7 +123,6 @@ def exists(self): return Exists(self) @property - @retry(UiObjectNotFoundError, delay=.5, tries=3, jitter=0.1, logger=logging) # yapf: disable def info(self): '''ui object info.''' return self.jsonrpc.objInfo(self.selector) @@ -157,7 +155,7 @@ def click(self, timeout=None, offset=None): # if delay: # time.sleep(delay) - def bounds(self): + def bounds(self) -> Tuple[int, int, int, int]: """ Returns: left_top_x, left_top_y, right_bottom_x, right_bottom_y @@ -204,7 +202,7 @@ def click_gone(self, maxretry=10, interval=1.0): maxretry -= 1 return False - def click_exists(self, timeout=0): + def click_exists(self, timeout=0) -> bool: try: self.click(timeout=timeout) return True @@ -314,7 +312,7 @@ def wait(self, exists=True, timeout=None): return self.jsonrpc.waitForExists(self.selector, int(timeout * 1000), http_timeout=http_wait) - except requests.ReadTimeout as e: + except HTTPError as e: warnings.warn("waitForExists readTimeout: %s" % e, RuntimeWarning) return self.exists() @@ -323,7 +321,7 @@ def wait(self, exists=True, timeout=None): return self.jsonrpc.waitUntilGone(self.selector, int(timeout * 1000), http_timeout=http_wait) - except requests.ReadTimeout as e: + except HTTPError as e: warnings.warn("waitForExists readTimeout: %s" % e, RuntimeWarning) return not self.exists() diff --git a/uiautomator2/assets/.gitignore b/uiautomator2/assets/.gitignore index cc51612c..09b71e44 100644 --- a/uiautomator2/assets/.gitignore +++ b/uiautomator2/assets/.gitignore @@ -1,3 +1,4 @@ *.apk atx-agent -version.txt \ No newline at end of file +version.json +*.jar \ No newline at end of file diff --git a/uiautomator2/assets/sync.sh b/uiautomator2/assets/sync.sh index 7cacf10d..99ec1d07 100755 --- a/uiautomator2/assets/sync.sh +++ b/uiautomator2/assets/sync.sh @@ -5,33 +5,37 @@ set -e APK_VERSION=$(cat ../version.py| grep apk_version | awk '{print $NF}') APK_VERSION=${APK_VERSION//[\"\']} +JAR_VERSION="0.1.0" cd "$(dirname $0)" -# function download_atx_agent() { -# VERSION=$1 -# NAME="tmp-atx-agent.tar.gz" -# URL="https://github.com/openatx/atx-agent/releases/download/$VERSION/atx-agent_${VERSION}_linux_armv6.tar.gz" -# echo "$URL" -# curl -L "$URL" --output "$NAME" -# tar -xzvf "$NAME" atx-agent -# rm -f "$NAME" -# } + +function download() { + local URL=$1 + local OUTPUT=$2 + echo ">> download $URL -> $OUTPUT" + curl -L "$URL" --output "$OUTPUT" +} function download_apk(){ - VERSION=$1 - NAME=$2 - URL="https://github.com/openatx/android-uiautomator-server/releases/download/$VERSION/$NAME" - echo ">> download $URL -> $NAME" - curl -L "$URL" --output "$NAME" + 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" } -# download_atx_agent "$AGENT_VERSION" -# echo "atx_agent_version: $AGENT_VERSION" >> version.txt +function download_jar() { + local URL="https://public.uiauto.devsleep.com/u2jar/$JAR_VERSION/u2.jar" + https_proxy= download "$URL" "u2.jar" +} echo "APK_VERSION: $APK_VERSION" +download_jar download_apk "$APK_VERSION" "app-uiautomator.apk" -download_apk "$APK_VERSION" "app-uiautomator-test.apk" -echo "apk_version: $APK_VERSION" > version.txt +cat > version.json < Optional[int]: def kill(self): self._conn.close() + self.wait() def launch_uiautomator(dev: adbutils.AdbDevice) -> MockAdbProcess: """Launch uiautomator2 server on device""" - logger.debug("launch uiautomator") - dev.shell("am force-stop com.github.uiautomator") - dev.shell("am start -n com.github.uiautomator/.ToastActivity") - # use command to see if uiautomator is running: ps -A | grep uiautomator - conn = dev.shell("am instrument -w -r -e debug false -e class com.github.uiautomator.stub.Stub com.github.uiautomator.test/androidx.test.runner.AndroidJUnitRunner", stream=True) + 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 @@ -94,7 +95,9 @@ def _http_request(dev: adbutils.AdbDevice, method: str, path: str, data: Dict[st # so here use 127.0.0.1 instead of localhost url = f"http://127.0.0.1:{lport}{path}" if print_request: - fields = [time.strftime("%H:%M:%S"), f"$ curl -X {method}", url] + start_time = datetime.datetime.now() + current_time = start_time.strftime("%H:%M:%S.%f")[:-3] + fields = [current_time, f"$ curl -X {method}", url] if data: fields.append(f"-d '{json.dumps(data)}'") print(f"# http timeout={timeout}") @@ -103,9 +106,11 @@ def _http_request(dev: adbutils.AdbDevice, method: str, path: str, data: Dict[st r.raise_for_status() response = HTTPResponse(r.content) if print_request: - print(f"{time.strftime('%H:%M:%S')} Response >>>") - print(response.text) - print(f"<<< END") + 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.RequestException as e: raise HTTPError(f"HTTP request failed: {e}") from e @@ -155,12 +160,13 @@ 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) -> None: self._dev = dev self._process = None - self._lock = threading.Lock() self._debug = False - self.start_uiautomator(_silent=True) + self.start_uiautomator() atexit.register(self.stop_uiautomator, wait=False) @property @@ -171,16 +177,7 @@ def debug(self) -> bool: def debug(self, value: bool): self._debug = bool(value) - def start_uiautomator(self, _silent: bool = False): - try: - self._do_start_uiautomator(silent=_silent) - except APKSignatureError as e: - logger.debug("APkSignatureError: %s", e) - self._dev.uninstall("com.github.uiautomator") - self._dev.uninstall("com.github.uiautomator.test") - self._do_start_uiautomator(silent=_silent) - - def _do_start_uiautomator(self, silent: bool): + def start_uiautomator(self): """ Start uiautomator2 server @@ -188,81 +185,63 @@ def _do_start_uiautomator(self, silent: bool): LaunchUiautomatorError: uiautomator2 server not ready """ with self._lock: - self._setup_apks() + 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(show_float_window=not silent) + self._wait_ready() - def _setup_apks(self): + def _setup_jar(self): assets_dir = Path(__file__).parent / "assets" - main_apk = assets_dir / "app-uiautomator.apk" - test_apk = assets_dir / "app-uiautomator-test.apk" - - logger.debug("use apk_version: %s", __apk_version__) - # install apk when not installed or version not match, dev version always keep - main_apk_info = self._dev.app_info("com.github.uiautomator") - if main_apk_info is None: - self._install_apk(main_apk) - elif main_apk_info.version_name != __apk_version__: - if re.match(r"([\d.]+)\-(\d+)\-\w+", main_apk_info.version_name) or "dirty" in main_apk_info.version_name: - logger.debug("skip version check for %s", main_apk_info.version_name) - elif is_version_compatiable(__apk_version__, main_apk_info.version_name): - logger.debug("apk version compatiable, expect %s, actual %s", __apk_version__, main_apk_info.version_name) - else: - logger.debug("apk version not ok, expect %s, actual %s", __apk_version__, main_apk_info.version_name) - self._dev.uninstall("com.github.uiautomator") - self._dev.uninstall("com.github.uiautomator.test") - self._install_apk(main_apk) - self._install_apk(test_apk) - - if self._dev.app_info("com.github.uiautomator.test") is None: - self._install_apk(test_apk) - - def _install_apk(self, apk_path: Path): - logger.debug("Install %s", apk_path) - self._dev.shell("mkdir -p /data/local/tmp/u2") - target_path = "/data/local/tmp/u2/" + apk_path.name - self._dev.push(apk_path, target_path) - # -r: replace - # -t: allow test packages - # -d: allow version code downgrade - self._dev.shell(['pm', 'install', '-r', '-t', '-d', target_path]) + jar_path = assets_dir / "u2.jar" + 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 _wait_instrument_ready(self, timeout: float): - """wait until "INSTRUMENTATION_STATUS_CODE: 1" show up""" - deadline = time.time() + timeout - while time.time() < deadline: - output = self._process.output.decode("utf-8", errors="ignore") - if "does not have a signature matching the target" in output: - raise APKSignatureError("app-uiautomator.apk does not have a signature matching the target") - if "INSTRUMENTATION_STATUS: Error=" in output: - error_message = output[output.find("INSTRUMENTATION_STATUS: Error="):].splitlines()[0] - raise LaunchUiAutomationError(error_message, output) - if "INSTRUMENTATION_STATUS_CODE:" in output: - status_code = int(re.search(r"INSTRUMENTATION_STATUS_CODE: (-?\d+)", output).group(1)) - if status_code == 1: # success - logger.debug("am instrument success, status_code: %d", status_code) - return - raise LaunchUiAutomationError("am instrument error", f'CODE:{status_code}', output) - if self._process.pool() is not None: - raise LaunchUiAutomationError("am instrument quit", output) - time.sleep(.5) - raise LaunchUiAutomationError("am instrument launch timeout", f"{timeout}s", output) + 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]) + 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_stub_ready(self, timeout: float): + 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("Possibly another UiAutomation service is running, you may find it output by \"adb shell ps -u shell\"",) + raise AccessibilityServiceAlreadyRegisteredError(output) if self._process.pool() is not None: - raise LaunchUiAutomationError("uiautomator2 server quit", output) + raise LaunchUiAutomationError("server quit unexpectly", output_buffer) if self._check_alive(): return - raise LaunchUiAutomationError("uiautomator2 server not ready") + time.sleep(.5) + raise LaunchUiAutomationError("server not ready", output_buffer) def _check_alive(self) -> bool: try: @@ -270,18 +249,6 @@ def _check_alive(self) -> bool: return response.content == b"pong" except HTTPError: return False - - def _wait_ready(self, launch_timeout=30, service_timeout=30, show_float_window: bool = True): - """Wait until uiautomator2 server is ready""" - # wait am instrument start - self._wait_instrument_ready(launch_timeout) - self._dev.shell("am startservice -a com.github.uiautomator.ACTION_START") - if show_float_window: - # launch a toast window to make sure uiautomator is alive - logger.debug("show float window") - self._dev.shell("am start -n com.github.uiautomator/.ToastActivity -e showFloatWindow true") - self._wait_stub_ready(service_timeout) - time.sleep(1) # wait ATX goto background def stop_uiautomator(self, wait=True): with self._lock: @@ -305,11 +272,3 @@ def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> self.stop_uiautomator() self.start_uiautomator() return _jsonrpc_call(self._dev, method, params, timeout, self._debug) - -class SimpleUiautomatorServer(BasicUiautomatorServer, AbstractUiautomatorServer): - @property - def info(self) -> Dict[str, Any]: - return self.jsonrpc_call("deviceInfo") - - def dump_hierarchy(self, compressed: bool = False, pretty: bool = False) -> str: - return self.jsonrpc_call("dumpWindowHierarchy", [compressed, pretty]) diff --git a/uiautomator2/exceptions.py b/uiautomator2/exceptions.py index f8046d94..9b37c262 100644 --- a/uiautomator2/exceptions.py +++ b/uiautomator2/exceptions.py @@ -1,11 +1,33 @@ # 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 """ -class MissingLibError(BaseException): ... - ## DeviceError class DeviceError(BaseException): ... class AdbShellError(DeviceError):... @@ -13,9 +35,8 @@ class ConnectError(DeviceError):... class HTTPError(DeviceError):... class AdbBroadcastError(DeviceError):... -class UiAutomationError(DeviceError): - pass - +class UiAutomationError(DeviceError):... +class InputIMEError(DeviceError):... class UiAutomationNotConnectedError(UiAutomationError):... class InjectPermissionError(UiAutomationError):... #开发者选项中: 模拟点击没有打开 @@ -39,4 +60,5 @@ class NormalError(RPCError): class XPathElementNotFoundError(NormalError):... class SessionBrokenError(NormalError):... #only happens when app quit or crash -class UiObjectNotFoundError(NormalError):... \ No newline at end of file +class UiObjectNotFoundError(NormalError):... +class AppNotFoundError(NormalError):... \ No newline at end of file diff --git a/uiautomator2/settings.py b/uiautomator2/settings.py index df9058cc..e63f4c5d 100644 --- a/uiautomator2/settings.py +++ b/uiautomator2/settings.py @@ -1,12 +1,11 @@ # coding: utf-8 # -import json import logging import pprint from typing import Any -logger = logging.getLogger("uiautomator2") +logger = logging.getLogger(__name__) class Settings(object): """ 赋值时会检查类型 """ @@ -54,8 +53,8 @@ def __set_operation_delay(self, value: tuple): 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" + 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) diff --git a/uiautomator2/swipe.py b/uiautomator2/swipe.py index 0c131911..0883bbfd 100644 --- a/uiautomator2/swipe.py +++ b/uiautomator2/swipe.py @@ -1,6 +1,6 @@ # coding: utf-8 -from typing import Union +from typing import Optional, Tuple, Union from ._proto import Direction @@ -16,7 +16,7 @@ def __init__(self, d): def __call__(self, direction: Union[Direction, str], scale: float = 0.9, - box: Union[None, tuple] = None, + box: Optional[Tuple[int, int, int, int]] = None, **kwargs): """ Args: diff --git a/uiautomator2/utils.py b/uiautomator2/utils.py index 3fc9ddef..5fffb5dd 100644 --- a/uiautomator2/utils.py +++ b/uiautomator2/utils.py @@ -11,7 +11,7 @@ from PIL import Image from uiautomator2._proto import Direction -from uiautomator2.exceptions import MissingLibError, SessionBrokenError, UiObjectNotFoundError +from uiautomator2.exceptions import SessionBrokenError, UiObjectNotFoundError def check_alive(fn): @@ -134,15 +134,7 @@ def inject_call(fn, *args, **kwargs): return fn(*ba.args, **ba.kwargs) -class ProgressReader: - def __init__(self, rd): - pass - - def read(self, size=-1): - pass - - -def natualsize(size: int): +def natualsize(size: int) -> str: _KB = 1 << 10 _MB = 1 << 20 _GB = 1 << 30 @@ -170,7 +162,6 @@ def swipe_in_bounds(d: "uiautomator2.Device", AssertionError, ValueError """ def _swipe(_from, _to): - print("SWIPE", _from, _to) d.swipe(_from[0], _from[1], _to[0], _to[1]) assert 0 < scale <= 1.0 @@ -257,12 +248,7 @@ def image_convert(im: Image.Image, format: str): im = im.convert("RGB") return cv2.cvtColor(np.array(im), cv2.COLOR_RGB2BGR) except ImportError: - raise MissingLibError("missing lib: cv2 or numpy") - if format == "raw": - return im.tobytes() + warnings.warn("missing lib: cv2 or numpy") + raise raise ValueError("Unsupported format:", format) - -if __name__ == "__main__": - for n in (1, 10000, 10000000, 10000000000): - print(n, natualsize(n)) diff --git a/uiautomator2/xpath.py b/uiautomator2/xpath.py index f1351422..87bd8493 100644 --- a/uiautomator2/xpath.py +++ b/uiautomator2/xpath.py @@ -380,7 +380,7 @@ def _get_page_source(self) -> PageSource: raise XPathError("self._parent is not set") return self._parent.get_page_source() - def all(self, source: PageSource=None) -> List["XMLElement"]: + def all(self, source: Optional[PageSource] = None) -> List["XMLElement"]: """find all matched elements""" if not source: source = self._get_page_source()