From b3a97abd788b4da553ef08ff126ee81c9b80690b Mon Sep 17 00:00:00 2001 From: codeskyblue Date: Mon, 27 May 2024 14:23:52 +0800 Subject: [PATCH] deprecated set_fastinput_ime, update send_action() (#980) * deprecated set_fastinput_ime, update send_action() * fix clipboard support, close #853 * remove deprecated method --- README.md | 37 ++++++--- docs/2to3.md | 10 ++- pyproject.toml | 1 - uiautomator2/__init__.py | 151 +++-------------------------------- uiautomator2/_input.py | 159 +++++++++++++++++++++++++++++++++++++ uiautomator2/core.py | 2 +- uiautomator2/exceptions.py | 1 + uiautomator2/utils.py | 15 +++- uiautomator2/version.py | 2 +- uiautomator2/xpath.py | 15 ++-- 10 files changed, 225 insertions(+), 168 deletions(-) create mode 100644 uiautomator2/_input.py diff --git a/README.md b/README.md index a76742e..2b2bc8c 100644 --- a/README.md +++ b/README.md @@ -553,12 +553,24 @@ Below is a possible output: ### Clipboard Get of set clipboard content -设置粘贴板内容或获取内容 (目前已知问题是9.0之后的后台程序无法获取剪贴板的内容) +设置粘贴板内容或获取内容 * clipboard/set_clipboard ```python - d.set_clipboard('text', 'label') + d.clipboard = 'hello-world' + # or + d.set_clipboard('hello-world', 'label') + + ``` + +Get clipboard content + +> get clipboard requires IME(com.github.uiautomator/.AdbKeyboard) call `d.set_input_ime()` before using it. + + ```python + + # get clipboard content print(d.clipboard) ``` @@ -1270,22 +1282,23 @@ Refs: [Google uiautomator Configurator](https://developer.android.com/reference/ 这种方法通常用于不知道控件的情况下的输入。第一步需要切换输入法,然后发送adb广播命令,具体使用方法如下 ```python -d.set_fastinput_ime(True) # 切换成FastInputIME输入法 d.send_keys("你好123abcEFG") # adb广播输入 -d.clear_text() # 清除输入框所有内容(Require android-uiautomator.apk version >= 1.0.7) -d.set_fastinput_ime(False) # 切换成正常的输入法 -d.send_action("search") # 模拟输入法的搜索 +d.send_keys("你好123abcEFG", clear=True) # adb广播输入 + +d.clear_text() # 清除输入框所有内容 + +d.send_action() # 根据输入框的需求,自动执行回车、搜索等指令, Added in version 3.1 +# 也可以指定发送的输入法action, eg: d.send_action("search") 支持 go, search, send, next, done, previous ``` -**send_action** 说明 -该函数可以使用的参数有 `go search send next done previous` -_什么时候该使用这个函数呢?_ +```python +print(d.current_ime()) # 获取当前输入法ID + +``` -有些时候在EditText中输入完内容之后,调用`press("search")` or `press("enter")`发现并没有什么反应。 -这个时候就需要`send_action`函数了,这里用到了只有输入法才能用的[IME_ACTION_CODE](https://developer.android.com/reference/android/view/inputmethod/EditorInfo)。 -`send_action`先broadcast命令发送给输入法操作`IME_ACTION_CODE`,由输入法完成后续跟EditText的通信。(原理我不太清楚,有了解的,提issue告诉我) +> 更多参考: [IME_ACTION_CODE](https://developer.android.com/reference/android/view/inputmethod/EditorInfo) ### Toast (2.2版本之后有添加回来) Show Toast (好像有点bug) diff --git a/docs/2to3.md b/docs/2to3.md index 41971cc..45ce326 100644 --- a/docs/2to3.md +++ b/docs/2to3.md @@ -65,6 +65,10 @@ XPath (d.xpath) methods - 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" @@ -169,6 +173,10 @@ print(d.device_info) 'version': 12} ``` - ### app_current +### 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" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 12c7a81..0bbcba6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,6 @@ requests = "*" lxml = "*" adbutils = "^2.5.0" retry = ">=0,<1" -Deprecated = "*" Pillow = "*" [tool.poetry.group.dev.dependencies] diff --git a/uiautomator2/__init__.py b/uiautomator2/__init__.py index dcea9a4..870f190 100644 --- a/uiautomator2/__init__.py +++ b/uiautomator2/__init__.py @@ -15,7 +15,6 @@ from typing import Any, Dict, List, Optional, Union import adbutils -from deprecated import deprecated from lxml import etree from retry import retry @@ -24,10 +23,11 @@ from uiautomator2 import xpath 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.settings import Settings from uiautomator2.swipe import SwipeExt -from uiautomator2.utils import list2cmdline +from uiautomator2.utils import list2cmdline, deprecated from uiautomator2.watcher import WatchContext, Watcher from uiautomator2.abstract import AbstractShell, AbstractUiautomatorServer, ShellResponse @@ -557,8 +557,9 @@ def exists(self, **kwargs): return self(**kwargs).exists @property - def clipboard(self): - return self.jsonrpc.getClipboard() + def clipboard(self) -> str: + return super().clipboard + # return self.jsonrpc.getClipboard() # FIXME(ssx): bug @clipboard.setter def clipboard(self, text: str): @@ -912,7 +913,7 @@ def click_post_delay(self): def click_post_delay(self, v: Union[int, float]): self.settings['post_delay'] = v - @deprecated(version="2.0.0", reason="use d.toast.show(text, duration) instead") + @deprecated(reason="use d.toast.show(text, duration) instead") def make_toast(self, text, duration=1.0): """ Show toast Args: @@ -928,119 +929,6 @@ def unlock(self): self.swipe(0.1, 0.9, 0.9, 0.1) -class _InputMethodMixIn(AbstractShell): - def set_fastinput_ime(self, enable: bool = True): - """ Enable of Disable FastInputIME """ - fast_ime = 'com.github.uiautomator/.FastInputIME' - if enable: - self.shell(['ime', 'enable', fast_ime]) - self.shell(['ime', 'set', fast_ime]) - else: - self.shell(['ime', 'disable', fast_ime]) - - def send_keys(self, text: str, clear: bool = False): - """ - Args: - text (str): text to set - clear (bool): clear before set text - - Raises: - EnvironmentError - """ - try: - self.wait_fastinput_ime() - btext = text.encode('utf-8') - base64text = base64.b64encode(btext).decode() - cmd = "ADB_SET_TEXT" if clear else "ADB_INPUT_TEXT" - self.shell( - ['am', 'broadcast', '-a', cmd, '--es', 'text', base64text]) - return True - except EnvironmentError: - warnings.warn( - "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): - """ - 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.wait_fastinput_ime() - __alias = { - "go": 2, - "search": 3, - "send": 4, - "next": 5, - "done": 6, - "previous": 7, - } - if isinstance(code, str): - code = __alias.get(code, code) - self.shell([ - 'am', 'broadcast', '-a', 'ADB_EDITOR_CODE', '--ei', 'code', - str(code) - ]) - - def clear_text(self): - """ clear text - Raises: - EnvironmentError - """ - try: - self.wait_fastinput_ime() - self.shell(['am', 'broadcast', '-a', 'ADB_CLEAR_TEXT']) - except EnvironmentError: - # for Android simulator - self(focused=True).clear_text() - - def wait_fastinput_ime(self, timeout=5.0): - """ wait FastInputIME is ready - Args: - timeout(float): maxium wait time - - Raises: - EnvironmentError - """ - # TODO: 模拟器待兼容 eg. Genymotion, 海马玩, Mumu - - deadline = time.time() + timeout - while time.time() < deadline: - ime_id, shown = self.current_ime() - if ime_id != "com.github.uiautomator/.FastInputIME": - self.set_fastinput_ime(True) - time.sleep(0.5) - continue - if shown: - return True - time.sleep(0.2) - raise EnvironmentError("FastInputIME started failed") - - def current_ime(self): - """ Current input method - Returns: - (method_id(str), shown(bool) - - Example output: - ("com.github.uiautomator/.FastInputIME", True) - """ - _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) - class _PluginMixIn: def watch_context(self, autostart: bool = True, builtin: bool = False) -> WatchContext: @@ -1071,32 +959,11 @@ def screenrecord(self): def swipe_ext(self) -> SwipeExt: return SwipeExt(self) -class Device(_Device, _AppMixIn, _PluginMixIn, _InputMethodMixIn, _DeprecatedMixIn): + +class Device(_Device, _AppMixIn, _PluginMixIn, InputMethodMixIn, _DeprecatedMixIn): """ Device object """ + pass - @property - def info(self) -> Dict[str, Any]: - """ return device info, make sure currentPackageName is set - - Return example: - {'currentPackageName': 'io.appium.android.apis', - 'displayHeight': 720, - 'displayRotation': 3, - 'displaySizeDpX': 780, - 'displaySizeDpY': 360, - 'displayWidth': 1560, - 'productName': 'ELE-AL00', - 'screenOn': True, - 'sdkInt': 29, - 'naturalOrientation': False} - """ - _info = super().info - if _info.get('currentPackageName') is None: - try: - _info['currentPackageName'] = self.app_current().get('package') - except DeviceError: - pass - return _info class Session(Device): """Session keeps watch the app status diff --git a/uiautomator2/_input.py b/uiautomator2/_input.py new file mode 100644 index 0000000..b7ef285 --- /dev/null +++ b/uiautomator2/_input.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +"""Created on Wed May 22 2024 16:23:56 by codeskyblue +""" + +import base64 +from dataclasses import dataclass +import re +from typing import Dict, Optional, Union +import warnings + +from retry import retry + +from uiautomator2.abstract import AbstractShell +from uiautomator2.exceptions import AdbBroadcastError, DeviceError +from uiautomator2.utils import deprecated + + +@dataclass +class BroadcastResult: + code: Optional[int] + data: Optional[str] + + +BORADCAST_RESULT_OK = -1 +BROADCAST_RESULT_CANCELED = 0 + + + +class InputMethodMixIn(AbstractShell): + @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 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]) + return + + if self.current_ime() == 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]) + + 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, clear: bool = False): + """ + Args: + text (str): text to set + clear (bool): clear before set text + """ + 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" + self._must_broadcast(cmd, {"text": base64text}) + return True + except AdbBroadcastError: + warnings.warn( + "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 + + 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): + """ clear text + Raises: + EnvironmentError + """ + try: + self.set_input_ime(True) + self._must_broadcast('ADB_KEYBOARD_CLEAR_TEXT') + except AdbBroadcastError: + # for Android simulator + self(focused=True).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) + + @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 diff --git a/uiautomator2/core.py b/uiautomator2/core.py index 5d7446c..0c9e5a4 100644 --- a/uiautomator2/core.py +++ b/uiautomator2/core.py @@ -205,7 +205,7 @@ def _setup_apks(self): if main_apk_info is None: self._install_apk(main_apk) elif main_apk_info.version_name != __apk_version__: - if "dev" in main_apk_info.version_name or "dirty" in main_apk_info.version_name: + 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) diff --git a/uiautomator2/exceptions.py b/uiautomator2/exceptions.py index c0fdfdc..2549d69 100644 --- a/uiautomator2/exceptions.py +++ b/uiautomator2/exceptions.py @@ -11,6 +11,7 @@ class DeviceError(BaseException): class AdbShellError(DeviceError):... class ConnectError(DeviceError):... class HTTPError(DeviceError):... +class AdbBroadcastError(DeviceError):... class UiAutomationError(DeviceError): pass diff --git a/uiautomator2/utils.py b/uiautomator2/utils.py index a0d6130..ea1c07c 100644 --- a/uiautomator2/utils.py +++ b/uiautomator2/utils.py @@ -7,6 +7,7 @@ import threading import typing from typing import Union +import warnings from uiautomator2._proto import Direction from uiautomator2.exceptions import SessionBrokenError, UiObjectNotFoundError @@ -233,8 +234,18 @@ def _parse_version(version: str): 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 + + if __name__ == "__main__": for n in (1, 10000, 10000000, 10000000000): print(n, natualsize(n)) diff --git a/uiautomator2/version.py b/uiautomator2/version.py index b7bd947..ae65c92 100644 --- a/uiautomator2/version.py +++ b/uiautomator2/version.py @@ -6,7 +6,7 @@ # see release note for details -__apk_version__ = '2.3.11' +__apk_version__ = '2.4.0' # old apk version history # 2.3.3 make float windows smaller diff --git a/uiautomator2/xpath.py b/uiautomator2/xpath.py index 9b8e958..f135142 100644 --- a/uiautomator2/xpath.py +++ b/uiautomator2/xpath.py @@ -12,14 +12,13 @@ import time from typing import Any, Callable, Dict, List, Optional, Tuple, Union -from deprecated import deprecated from PIL import Image from lxml import etree from uiautomator2._proto import Direction from uiautomator2.abstract import AbstractXPathBasedDevice from uiautomator2.exceptions import XPathElementNotFoundError -from uiautomator2.utils import inject_call, swipe_in_bounds +from uiautomator2.utils import inject_call, swipe_in_bounds, deprecated logger = logging.getLogger(__name__) @@ -198,28 +197,28 @@ def get_page_source(self) -> PageSource: def match(self, xpath, source=None): return len(self(xpath, source).all()) > 0 - @deprecated(version="3.0.0", reason="use d.watcher.when(..) instead") + @deprecated(reason="use d.watcher.when(..) instead") def when(self, xquery: str): return self._watcher.when(xquery) - @deprecated(version="3.0.0", reason="use d.watcher.run() instead") + @deprecated(reason="use d.watcher.run() instead") def run_watchers(self, source=None): self._watcher.run() - @deprecated(version="3.0.0", reason="use d.watcher.start(..) instead") + @deprecated(reason="use d.watcher.start(..) instead") def watch_background(self, interval: float = 4.0): return self._watcher.start(interval) - @deprecated(version="3.0.0", reason="use d.watcher.stop() instead") + @deprecated(reason="use d.watcher.stop() instead") def watch_stop(self): """stop watch background""" self._watcher.stop() - @deprecated(version="3.0.0", reason="use d.watcher.remove() instead") + @deprecated(reason="use d.watcher.remove() instead") def watch_clear(self): self._watcher.stop() - @deprecated(version="3.0.0", reason="removed") + @deprecated(reason="removed") def sleep_watch(self, seconds): """run watchers when sleep""" deadline = time.time() + seconds