├── .coveragerc ├── .github ├── dependabot.yml └── workflows │ ├── main.yml │ └── publish-to-pypi.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── adbutils ├── __init__.py ├── __main__.py ├── _adb.py ├── _deprecated.py ├── _device.py ├── _proto.py ├── _utils.py ├── _version.py ├── binaries │ ├── README.md │ └── __init__.py ├── errors.py ├── install.py ├── pidcat.py ├── py.typed ├── screenrecord.py ├── screenshot.py ├── shell.py └── sync.py ├── assets └── images │ └── pidcat.png ├── build_wheel.py ├── codecov.yml ├── docs └── PROTOCOL.md ├── examples └── reset-offline.py ├── pytest.ini ├── requirements.txt ├── setup.cfg ├── setup.py ├── test_real_device ├── conftest.py ├── test_adb.py ├── test_deprecated.py ├── test_device.py ├── test_forward_reverse.py ├── test_import.py ├── test_record.py └── test_utils.py └── tests ├── adb_server.py ├── conftest.py ├── test_adb_server.py ├── test_adb_shell.py ├── test_devices.py └── test_forward.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = true 3 | omit = 4 | tests/* 5 | test_real_device/* 6 | docs/* 7 | setup.py 8 | build_wheel.py 9 | 10 | 11 | [report] 12 | ; Regexes for lines to exclude from consideration 13 | exclude_also = 14 | ; Don't complain about missing debug-only code: 15 | def __repr__ 16 | if self\.debug 17 | 18 | ; Don't complain if tests don't hit defensive assertion code: 19 | raise AssertionError 20 | raise NotImplementedError 21 | 22 | ; Don't complain if non-runnable code isn't run: 23 | if 0: 24 | if __name__ == .__main__.: 25 | 26 | ; Don't complain about abstract methods, they aren't run: 27 | @(abc\.)?abstractmethod 28 | 29 | ignore_errors = True 30 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Python Package 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - 'docs/**' 7 | branches: 8 | - master 9 | - dev 10 | pull_request: 11 | paths-ignore: 12 | - 'docs/**' 13 | branches: 14 | - '**' 15 | 16 | concurrency: 17 | group: tests-${{ github.head_ref || github.ref }} 18 | cancel-in-progress: ${{ github.event_name == 'pull_request' }} 19 | 20 | jobs: 21 | test: 22 | name: ${{ matrix.os }} / ${{ matrix.python-version }} 23 | runs-on: ${{ matrix.image }} 24 | strategy: 25 | matrix: 26 | os: [Ubuntu] 27 | python-version: ["3.8", "3.12"] 28 | include: 29 | - os: Ubuntu 30 | image: ubuntu-latest 31 | fail-fast: false 32 | steps: 33 | - uses: actions/checkout@v4 34 | 35 | - name: Set up Python ${{ matrix.python-version }} 36 | uses: actions/setup-python@v4 37 | with: 38 | python-version: ${{ matrix.python-version }} 39 | 40 | - name: Get full Python version 41 | id: full-python-version 42 | run: echo version=$(python -c "import sys; print('-'.join(str(v) for v in sys.version_info))") >> $GITHUB_OUTPUT 43 | 44 | - name: Install dependencies 45 | run: | 46 | pip install -e . 47 | pip install pytest pytest-cov 48 | 49 | 50 | - name: Run tests with coverage 51 | run: | 52 | pytest --cov=. --cov-report xml --cov-report term 53 | 54 | - name: Upload coverage to Codecov 55 | uses: codecov/codecov-action@v3 56 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distributions 📦 to PyPI 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | jobs: 7 | build-n-publish: 8 | name: Build and publish Python 🐍 distributions 📦 to PyPI 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@master 12 | with: 13 | fetch-depth: 5 14 | - name: Set up Python 15 | uses: actions/setup-python@v5 16 | with: 17 | python-version: 3.9 # minimum version is 3.9 18 | - name: Install pypa/build 19 | run: | 20 | python3 -m pip install wheel 21 | python3 -m pip install -r requirements.txt 22 | - name: Build targz and wheel 23 | run: | 24 | python3 setup.py sdist 25 | python3 build_wheel.py 26 | - name: Publish distribution 📦 to PyPI 27 | if: startsWith(github.ref, 'refs/tags') 28 | uses: pypa/gh-action-pypi-publish@release/v1 29 | with: 30 | skip-existing: true 31 | password: ${{ secrets.PYPI_API_TOKEN }} 32 | 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | AUTHORS 107 | ChangeLog 108 | .vscode/ 109 | .DS_Store 110 | .idea/ -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '3.6' 4 | - nightly 5 | script: echo skip test 6 | deploy: 7 | skip_existing: true 8 | provider: pypi 9 | user: codeskyblue 10 | password: 11 | secure: Qdf0yMd/Yc4MkPACeEogGrX+X1E47gNl99+iw63Znv7H326l4mOKiioJr6LinJcZNfOhDG/GWuzUqMrtpIgj/SEp2CyTolTk5pClgsxZqH8NzYKnlhIt59loFyO7aFcUtu3wa/RJ5GrTsBQObSYDhlhpTQ++N0Nhb481RfhTvmXnHRUT9dfL7m9VKP9X8ObyLOA1BhaFUwJz6vGomW4+d54dtee3lv2JDMAPveiWLDXAPs+Dh2+GrqwpOwNTuNChc1d8vqKznt3ZYZgWDeuFMQtlHH5A/40UscFS5CbiMVw3xXhYL6HWG6FzgsMTewJx9ZJp372NasPJefYYJ+EPHOsJhODY/uEx4fzK79U13x6K6Fcx71hoMWy0q3UEJjO2fOQshET4YkNZAdDhc9q1QXK75uKhZVFmRaV9sgKVLkJHHxoGOhWdWaB3KhuCCe7dv4JtI9/obrN+ew0lO+Mno5vBEZuz6Hih1Xe21fbATAciU79TJf1OudL1nariuVig7g03mKv/hBWe/4oRiI3mdHlm2yWyaTDb3QRn3wXLS6JW5+qj8TFwMLH7qAihwb4kWqrBm9m8vcsIr3m+/NdoVlhULVMdhNzuhE+FBVNZPlr3U9pkrKuCfSIyfTYfVBvwAnEOQ0HarxWmWKNNc7QewNeRrR5KS7t6Kx90EHDOKiU= 12 | distributions: "build_wheel" # default is "sdist" 13 | on: 14 | tags: true 15 | repo: openatx/adbutils 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 openatx 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # adbutils 2 | [![PyPI](https://img.shields.io/pypi/v/adbutils.svg?color=blue)](https://pypi.org/project/adbutils/#history) 3 | [![codecov](https://codecov.io/gh/openatx/adbutils/graph/badge.svg?token=OuGOMZUkmi)](https://codecov.io/gh/openatx/adbutils) 4 | 5 | Python adb library for adb service 6 | 7 | # Requires 8 | Python 3.8+ 9 | 10 | **Table of Contents** 11 | 12 | 13 | * [adbutils](#adbutils) 14 | * [Install](#install) 15 | * [Usage](#usage) 16 | * [Connect ADB Server](#connect-adb-server) 17 | * [List all the devices and get device object](#list-all-the-devices-and-get-device-object) 18 | * [Connect remote device](#connect-remote-device) 19 | * [adb forward and adb reverse](#adb-forward-and-adb-reverse) 20 | * [Create socket connection to the device](#create-socket-connection-to-the-device) 21 | * [Run shell command](#run-shell-command) 22 | * [Transfer files](#transfer-files) 23 | * [Extended Functions](#extended-functions) 24 | * [Run in command line 命令行使用](#run-in-command-line-命令行使用) 25 | * [Environment variables](#environment-variables) 26 | * [Color Logcat](#color-logcat) 27 | * [Experiment](#experiment) 28 | * [Examples](#examples) 29 | * [Develop](#develop) 30 | * [Watch adb socket data](#watch-adb-socket-data) 31 | * [Thanks](#thanks) 32 | * [Ref](#ref) 33 | * [LICENSE](#license) 34 | 35 | 36 | 37 | 38 | 39 | # Install 40 | ``` 41 | pip3 install adbutils 42 | 43 | # or 44 | pip3 install adbutils[all] 45 | ``` 46 | 47 | # Usage 48 | Example 49 | 50 | ## Connect ADB Server 51 | ```python 52 | import adbutils 53 | 54 | adb = adbutils.AdbClient(host="127.0.0.1", port=5037) 55 | for info in adb.list(): 56 | print(info.serial, info.state) 57 | # 58 | 59 | # only list state=device 60 | print(adb.device_list()) 61 | 62 | # Set socket timeout to 10 (default None) 63 | adb = adbutils.AdbClient(host="127.0.0.1", port=5037, socket_timeout=10) 64 | print(adb.device_list()) 65 | ``` 66 | 67 | The above code can be short to `from adbutils import adb` 68 | 69 | ## List all the devices and get device object 70 | ```python 71 | from adbutils import adb 72 | 73 | for d in adb.device_list(): 74 | print(d.serial) # print device serial 75 | 76 | d = adb.device(serial="33ff22xx") 77 | 78 | # or 79 | d = adb.device(transport_id=24) # transport_id can be found in: adb devices -l 80 | 81 | # You do not need to offer serial if only one device connected 82 | # RuntimeError will be raised if multi device connected 83 | d = adb.device() 84 | ``` 85 | 86 | The following code will not write `from adbutils import adb` for short 87 | 88 | ## Connect or disconnect remote device 89 | Same as command `adb connect` 90 | 91 | ```python 92 | output = adb.connect("127.0.0.1:5555") 93 | print(output) 94 | # output: already connected to 127.0.0.1:5555 95 | 96 | # connect with timeout 97 | try: 98 | adb.connect("127.0.0.1:5555", timeout=3.0) 99 | except AdbTimeout as e: 100 | print(e) 101 | 102 | adb.disconnect("127.0.0.1:5555") 103 | adb.disconnect("127.0.0.1:5555", raise_error=True) # if device is not present, AdbError will raise 104 | 105 | # wait-for-device 106 | adb.wait_for("127.0.0.1:5555", state="device") # wait for device online, state default value is "device" 107 | adb.wait_for("127.0.0.1:5555", state="disconnect") # wait device disconnect 108 | ``` 109 | 110 | ## adb forward and adb reverse 111 | Same as `adb forward --list` and `adb reverse --list` 112 | 113 | ```python 114 | # list all forwards 115 | for item in adb.forward_list(): 116 | print(item.serial, item.local, item.remote) 117 | # 8d1f93be tcp:10603 tcp:7912 118 | # 12345678 tcp:10664 tcp:7912 119 | 120 | # list only one device forwards 121 | for item in adb.forward_list("8d1f93be"): 122 | print(item.serial, item.local, item.remote) 123 | # 8d1f93be tcp:10603 tcp:7912 124 | # 12345678 tcp:10664 tcp:7912 125 | 126 | 127 | for item in adb.reverse_list(): 128 | print(item.serial, item.local, item.remote) 129 | 130 | # 监控设备连接 track-devices 131 | for event in adb.track_devices(): 132 | print(event.present, event.serial, event.status) 133 | 134 | ## When plugin two device, output 135 | # True WWUDU16C22003963 device 136 | # True bf755cab device 137 | # False bf755cab absent 138 | 139 | # When adb-server killed, AdbError will be raised 140 | ``` 141 | 142 | ## Create socket connection to the device 143 | 144 | For example 145 | 146 | ```python 147 | # minitouch: https://github.com/openstf/minitouch 148 | c = d.create_connection("unix", "minitouch") 149 | print(c.recv(500)) 150 | c.close() 151 | ``` 152 | 153 | ```python 154 | c = d.create_connection("tcp", 7912) # the second argument must be int 155 | c.send(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") 156 | print(c.recv(500)) 157 | c.close() 158 | ``` 159 | 160 | ```python 161 | # read device file 162 | with d.create_connection(adbutils.Network.DEV, "/data/local/tmp/hello.txt") as c: 163 | print(c.recv(500)) 164 | ``` 165 | 166 | There are many other usage, see [SERVICES.TXT](https://cs.android.com/android/platform/superproject/+/master:packages/modules/adb/SERVICES.TXT;l=175) for more details 167 | 168 | Thanks for Pull Request from [@hfutxqd](https://github.com/openatx/adbutils/pull/27) 169 | 170 | ## Run shell command 171 | I assume there is only one device connected. 172 | 173 | ```python 174 | import io 175 | from adbutils import adb 176 | 177 | d = adb.device() 178 | 179 | print(d.serial) # 获取序列号 180 | 181 | # Argument support list, str 182 | serial = d.shell(["getprop", "ro.serial"]) # 获取Prop信息 183 | 184 | # Same as 185 | serial = d.shell("getprop ro.serial") 186 | 187 | # Set timeout for shell command 188 | d.shell("sleep 1", timeout=0.5) # Should raise adbutils.AdbTimeout 189 | 190 | # The advanced shell (returncode archieved by add command suffix: ;echo EXIT:$?) 191 | ret = d.shell2("echo 1") 192 | print(ret) 193 | # expect: ShellReturn(args='echo 1', returncode=0, output='1\n') 194 | 195 | # Advanced shell using the shell v2 protocol 196 | ret = d.shell2("echo 1; echo 2 1>&2", v2=True) 197 | print(ret) 198 | # expect: ShellReturn(command='echo 1; echo 2 1>&2', returncode=0, output='1\n2\n', stderr='2\n', stdout='1\n') 199 | 200 | # show property, also based on d.shell 201 | print(d.prop.name) # output example: surabaya 202 | d.prop.model 203 | d.prop.device 204 | d.prop.get("ro.product.model") 205 | d.prop.get("ro.product.model", cache=True) # a little faster, use cache data first 206 | 207 | d.get_serialno() # same as adb get-serialno 208 | d.get_devpath() # same as adb get-devpath 209 | d.get_state() # same as adb get-state 210 | ``` 211 | 212 | Take screenshot 213 | 214 | ```python 215 | # Method 1 (Recommend) 216 | pil_image = d.screenshot() 217 | # default display_id=0, error_ok=True 218 | try: 219 | pil_image = d.screenshot(display_id=1, error_ok=False) 220 | except AdbError: 221 | print("failed to takeScreenshot") 222 | 223 | # Method 2 224 | # adb exec-out screencap -p p.png 225 | png_data = d.shell("screencap -p", encoding=None) 226 | pathlib.Path("p.png").write_bytes(png_data) 227 | ``` 228 | 229 | ## Transfer files 230 | ```python 231 | d.sync.push(b"Hello Android", "/data/local/tmp/hi.txt") # 推送二进制文本 232 | d.sync.push(io.BytesIO(b"Hello Android"), "/data/local/tmp/hi.txt") # 推送可读对象Readable object 233 | d.sync.push("/tmp/hi.txt", "/data/local/tmp/hi.txt") # 推送本地文件 234 | d.sync.push(pathlib.Path("/tmp/hi.txt"), "/data/local/tmp/hi.txt") # 推送本地文件 235 | 236 | # 读取文件 237 | for chunk in d.sync.iter_content("/data/local/tmp/hi.txt"): 238 | print("Chunk", chunk) 239 | 240 | d.sync.push(b"Hello world", "/data/local/tmp/hi.txt") 241 | output = d.sync.read_text("/data/local/tmp/hi.txt", encoding="utf-8") 242 | # Expect output: "Hello world" 243 | output = d.sync.read_bytes("/data/local/tmp/hi.txt") 244 | # Expect output: b"Hello world" 245 | 246 | # 拷贝到本地 247 | d.sync.pull("/data/local/tmp/hi.txt", "hi.txt") 248 | 249 | # 获取包的信息 250 | info = d.app_info("com.example.demo") 251 | if info: 252 | print(info) 253 | # output example: 254 | # { 255 | # "version_name": "1.2.3", "version_code": "12", "signature": "0xff132", 256 | # "first_install_time": datetime-object, "last_update_time": datetime-object, 257 | # } 258 | ``` 259 | 260 | ## Extended Functions 261 | 262 | AdbUtils provided some custom functions for some complex operations. 263 | 264 | You can use it like this: 265 | 266 | ```python 267 | # save screenshot 268 | pilimg = d.screenshot() 269 | pilimg.save("screenshot.jpg") 270 | 271 | # get current app info 272 | app_info = d.app_current() 273 | print(app_info.package) 274 | print(app_info.activity) 275 | print(app_info.pid) # might be 0 276 | 277 | # install apk 278 | d.install("apidemo.apk") # use local path 279 | d.install("http://example.com/apidemo.apk") # install from url 280 | # raise AdbInstallError if something went wrong 281 | 282 | # simulate click 283 | d.click(100, 100) 284 | d.click(0.5, 0.5) # center, should be float and <= 1.0 285 | 286 | # swipe from(10, 10) to(200, 200) 500ms 287 | d.swipe(10, 10, 200, 200, 0.5) 288 | 289 | d.list_packages() 290 | # example output: ["com.example.hello"] 291 | 292 | d.window_size() 293 | # example output: (1080, 1920) when phone is portrait 294 | # example output: (1920, 1080) when phone is landscape 295 | d.window_size(landscape=True) # force landscape mode 296 | # example output: (1920, 1080) 297 | 298 | d.rotation() -> int 299 | # example output: 0 300 | # 0: natural, 1: left, 2: right, 3: upsidedown 301 | 302 | d.app_info("com.github.uiautomator") 303 | # example output: {"version_name": "1.1.7", "version_code": "1007"} 304 | 305 | d.keyevent("HOME") 306 | 307 | d.volume_up() 308 | d.volume_down() 309 | # default times=1, If you want to adjust the volume multiple times, you can use:d.volume_up(times=xxx) 310 | 311 | d.volume_mute() # device mute 312 | 313 | d.send_keys("hello world$%^&*") # simulate: adb shell input text "hello%sworld\%\^\&\*" 314 | 315 | d.open_browser("https://www.baidu.com") # 打开百度 316 | # There still too many functions, please see source codes 317 | 318 | # check if screen is on 319 | d.is_screen_on() # 返回屏幕是否亮屏 True or False 320 | 321 | # adb root 322 | d.root() 323 | 324 | # adb tcpip 325 | d.tcpip(5555) 326 | 327 | print(d.battery()) # get battery info 328 | BatteryInfo(ac_powered=False, usb_powered=False, wireless_powered=False, dock_powered=False, max_charging_current=0, max_charging_voltage=0, charge_counter=10000, status=4, health=2, present=True, level=100, scale=100, voltage=5000, temperature=25.0, technology='Li-ion') 329 | 330 | print(d.brightness_value) # get brightness value, return int value in 0-255 331 | d.brightness_value = 100 # set brightness value 332 | 333 | # you can also set brightness mode 334 | from adbutils import BrightnessMode 335 | print(d.brightness_mode) # output BrightnessMode.AUTO or BrightnessMode.MANUAL 336 | d.brightness_mode = BrightnessMode.MANUAL # set brightness mode is manual 337 | d.brightness_mode = BrightnessMode.AUTO # set brightness mode is auto 338 | 339 | ``` 340 | 341 | Screenrecord (mp4) 342 | 343 | ```python 344 | d.start_recording("video.mp4") 345 | time.sleep(5) 346 | d.stop_recording() 347 | ``` 348 | 349 | Logcat 350 | 351 | ```python 352 | # filter logcat to file 353 | logcat = d.logcat("logcat.txt", clear=True, re_filter=".*FA.*") # clear default False 354 | # do something else 355 | logcat.stop(timeout=3) # tell thread to stop write, wait for 3s, if not stopped, raise TimeoutError 356 | logcat.stop_nowait() # tell thread to stop write and close file 357 | ``` 358 | 359 | 360 | > Screenrecord will try to use scrcpy first if scrcpy found in $PATH, then fallback to `adb shell screenrecord` 361 | 362 | _Note: The old method d.screenrecord() is removed after 0.16.2_ 363 | 364 | 375 | 376 | For further usage, please read [_device.py](adbutils/_device.py) for details. 377 | 378 | ## Run in command line 命令行使用 379 | 380 | ```bash 381 | # List devices 382 | $ python -m adbutils -l 383 | 8d1f93be MI 5s 384 | 192.168.190.101:5555 Google Nexus 5X - 7.0.0 - API 24 - 1080x1920 385 | 386 | # Show adb server version 387 | $ python -m adbutils -V 388 | 39 389 | 390 | # Install apk from local filesystem 安装本地apk(带有进度) 391 | $ python -m adbutils -i some.apk 392 | # Install apk from URL 通过URL安装apk(带有进度) 393 | $ python -m adbutils -i http://example.com/some.apk 394 | # Install and launch (-L or --launch) 395 | $ python -m adbutils -i http://example.com/some.apk -L 396 | 397 | # Parse apk info (support URL and local) 398 | $ python -m adbutils --parse http://example.com/some.apk 399 | $ python -m adbutils --parse some.apk 400 | package: com.example.some 401 | main-activity: com.example.some.MainActivity 402 | version-name: 1.0.0 403 | version-code: 100 404 | 405 | # Uninstall 卸载应用 406 | $ python -m adbutils -u com.github.example 407 | 408 | # Push 409 | $ python -m adbutils --push local.txt:/sdcard/remote.txt 410 | 411 | # Pull 412 | $ python -m adbutils --pull /sdcard/remote.txt # save to ./remote.txt 413 | 414 | # List installed packages 列出所有应用 415 | $ python -m adbutils --list-packages 416 | com.android.adbkeyboard 417 | com.buscode.whatsinput 418 | com.finalwire.aida64 419 | com.github.uiautomator 420 | 421 | # Show URL of file QRCode 422 | $ python -m adbutils --qrcode some.apk 423 | .--------. 424 | | | 425 | | qrcode | 426 | | | 427 | \--------/ 428 | 429 | # screenshot with screencap 430 | $ python -m adbutils --screenshot screen.jpg 431 | 432 | # download minicap, minicap.so to device 433 | $ python -m adbutils --minicap 434 | 435 | # take screenshot with minicap 436 | $ python -m adbutils --minicap --screenshot screen.jpg # screenshot with minicap 437 | 438 | # Show more info for developers 439 | $ python -m adbutils --dump-info 440 | ==== ADB Info ==== 441 | Path: /usr/local/bin/adb 442 | Server version: 41 443 | 444 | >> List of devices attached 445 | - 9de75303 picasso Redmi K30 5G 446 | 447 | # Track device status, function like: watch adb devices 448 | $ python -m adbutils --track 449 | 15:09:59.534 08a3d291 -> device 450 | 15:10:02.683 08a3d291 -> absent 451 | 15:10:05.196 08a3d291 -> offline 452 | 15:10:06.545 08a3d291 -> absent 453 | 15:10:06.545 08a3d291 -> device 454 | ``` 455 | 456 | ### Environment variables 457 | 458 | ```bash 459 | ANDROID_SERIAL serial number to connect to 460 | ANDROID_ADB_SERVER_HOST adb server host to connect to 461 | ANDROID_ADB_SERVER_PORT adb server port to connect to 462 | ``` 463 | 464 | ### Color Logcat 465 | 466 | For convenience of using logcat, I put put pidcat inside. 467 | 468 | ```bash 469 | python3 -m adbutils.pidcat [package] 470 | ``` 471 | 472 | ![](assets/images/pidcat.png) 473 | 474 | 475 | ## Experiment 476 | Install Auto confirm supported(Beta), you need to famillar with [uiautomator2](https://github.com/openatx/uiautomator2) first 477 | 478 | ```bash 479 | # Install with auto confirm (Experiment, based on github.com/openatx/uiautomator2) 480 | $ python -m adbutils --install-confirm -i some.apk 481 | ``` 482 | 483 | For more usage, please see the code for details. 484 | 485 | ## Examples 486 | Record video using screenrecord 487 | 488 | ```python 489 | stream = d.shell("screenrecord /sdcard/s.mp4", stream=True) 490 | time.sleep(3) # record for 3 seconds 491 | with stream: 492 | stream.send(b"\003") # send Ctrl+C 493 | stream.read_until_close() 494 | 495 | start = time.time() 496 | print("Video total time is about", time.time() - start) 497 | d.sync.pull("/sdcard/s.mp4", "s.mp4") # pulling video 498 | ``` 499 | 500 | Reading Logcat 501 | 502 | ```python 503 | d.shell("logcat --clear") 504 | stream = d.shell("logcat", stream=True) 505 | with stream: 506 | f = stream.conn.makefile() 507 | for _ in range(100): # read 100 lines 508 | line = f.readline() 509 | print("Logcat:", line.rstrip()) 510 | f.close() 511 | ``` 512 | 513 | # Develop 514 | ```sh 515 | git clone https://github.com/openatx/adbutils adbutils 516 | pip3 install -e adbutils # install as development mode 517 | ``` 518 | 519 | Now you can edit code in `adbutils` and test with 520 | 521 | ```python 522 | import adbutils 523 | # .... test code here ... 524 | ``` 525 | 526 | Run tests requires one device connected to your computer 527 | 528 | ```sh 529 | # change to repo directory 530 | cd adbutils 531 | 532 | pip3 install pytest 533 | pytest tests/ 534 | ``` 535 | 536 | # Environment 537 | Some environment can affect the adbutils behavior 538 | 539 | - ADBUTILS_ADB_PATH: specify adb path, default search from PATH 540 | - ANDROID_SERIAL: default adb serial 541 | - ANDROID_ADB_SERVER_HOST: default 127.0.0.1 542 | - ANDROID_ADB_SERVER_PORT: default 5037 543 | 544 | ## Watch adb socket data 545 | Watch the adb socket data using `socat` 546 | 547 | ``` 548 | $ socat -t100 -x -v TCP-LISTEN:5577,reuseaddr,fork TCP4:localhost:5037 549 | ``` 550 | 551 | open another terminal, type the following command then you will see the socket data 552 | 553 | ```bash 554 | $ export ANDROID_ADB_SERVER_PORT=5577 555 | $ adb devices 556 | ``` 557 | 558 | ## Changes from 1.x to 2.x 559 | 560 | ### Remove 561 | - current_app removed, use app_current instead 562 | - package_info is going to remove, use app_info instead 563 | 564 | ### Add 565 | - add volume_up, volume_down, volume_mute 566 | 567 | ## Generate TOC 568 | ```bash 569 | gh-md-toc --insert README.md 570 | ``` 571 | 572 | 573 | 574 | # Thanks 575 | - [swind pure-python-adb](https://github.com/Swind/pure-python-adb) 576 | - [openstf/adbkit](https://github.com/openstf/adbkit) 577 | - [ADB Source Code](https://android.googlesource.com/platform/system/core/+/android-4.4_r1/adb/adb.c) 578 | - [Awesome ADB](https://github.com/mzlogin/awesome-adb) 579 | - [JakeWharton/pidcat](https://github.com/JakeWharton/pidcat) 580 | 581 | # Develop 582 | [PROTOCOL.md](docs/PROTOCOL.md) 583 | 584 | # Alternative 585 | - https://github.com/Swind/pure-python-adb 586 | 587 | # Ref 588 | - 589 | 590 | # LICENSE 591 | [MIT](LICENSE) 592 | -------------------------------------------------------------------------------- /adbutils/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | 4 | from __future__ import print_function 5 | 6 | import io 7 | import os 8 | import typing 9 | 10 | from typing import Iterator, List, Optional, Union 11 | from deprecation import deprecated 12 | 13 | from adbutils._adb import AdbConnection 14 | from adbutils._adb import BaseClient as _BaseClient 15 | from adbutils._device import AdbDevice, Sync 16 | from adbutils._proto import * 17 | from adbutils._utils import adb_path, StopEvent 18 | from adbutils._version import __version__ 19 | from adbutils.errors import * 20 | 21 | 22 | class AdbClient(_BaseClient): 23 | def sync(self, serial: str) -> Sync: 24 | return Sync(self, serial) 25 | 26 | @deprecated(deprecated_in="0.15.0", 27 | removed_in="1.0.0", 28 | current_version=__version__, 29 | details="use AdbDevice.shell instead") 30 | def shell(self, 31 | serial: str, 32 | command: Union[str, list, tuple], 33 | stream: bool = False, 34 | timeout: Optional[float] = None) -> Union[str, AdbConnection]: 35 | return self.device(serial).shell(command, stream=stream, timeout=timeout, encoding='utf-8') 36 | 37 | def list(self, extended=False) -> List[AdbDeviceInfo]: 38 | """ 39 | Returns: 40 | list of device info, including offline 41 | """ 42 | infos = [] 43 | with self.make_connection() as c: 44 | if extended: 45 | c.send_command("host:devices-l") 46 | else: 47 | c.send_command("host:devices") 48 | c.check_okay() 49 | output = c.read_string_block() 50 | for line in output.splitlines(): 51 | parts = line.split() 52 | tags = {} 53 | num_required_fields = 2 # serial and state 54 | if len(parts) < num_required_fields: 55 | continue 56 | if extended: 57 | tags = {**tags, **{kv[0]: kv[1] for kv in list(map(lambda pair: pair.split(":"), parts[num_required_fields:]))}} 58 | infos.append(AdbDeviceInfo(serial=parts[0], state=parts[1], tags=tags)) 59 | return infos 60 | 61 | def iter_device(self) -> Iterator[AdbDevice]: 62 | """ 63 | Returns: 64 | iter only AdbDevice with state:device 65 | """ 66 | for info in self.list(): 67 | if info.state != "device": 68 | continue 69 | yield AdbDevice(self, serial=info.serial) 70 | 71 | def device_list(self) -> typing.List[AdbDevice]: 72 | return list(self.iter_device()) 73 | 74 | def device(self, 75 | serial: Optional[str] = None, 76 | transport_id: Optional[int] = None) -> AdbDevice: 77 | if serial: 78 | return AdbDevice(self, serial=serial) 79 | 80 | if transport_id: 81 | return AdbDevice(self, transport_id=transport_id) 82 | 83 | serial = os.environ.get("ANDROID_SERIAL") 84 | if not serial: 85 | ds = self.device_list() 86 | if len(ds) == 0: 87 | raise AdbError("Can't find any android device/emulator") 88 | if len(ds) > 1: 89 | raise AdbError( 90 | "more than one device/emulator, please specify the serial number" 91 | ) 92 | return ds[0] 93 | return AdbDevice(self, serial) 94 | 95 | 96 | 97 | adb = AdbClient() 98 | device = adb.device 99 | 100 | 101 | if __name__ == "__main__": 102 | print("server version:", adb.server_version()) 103 | print("devices:", adb.device_list()) 104 | d = adb.device_list()[0] 105 | 106 | print(d.serial) 107 | for f in adb.sync(d.serial).iter_directory("/data/local/tmp"): 108 | print(f) 109 | 110 | finfo = adb.sync(d.serial).stat("/data/local/tmp") 111 | print(finfo) 112 | import io 113 | sync = adb.sync(d.serial) 114 | filepath = "/data/local/tmp/hi.txt" 115 | sync.push(io.BytesIO(b"hi5a4de5f4qa6we541fq6w1ef5a61f65ew1rf6we"), 116 | filepath, 0o644) 117 | 118 | print("FileInfo", sync.stat(filepath)) 119 | for chunk in sync.iter_content(filepath): 120 | print(chunk) 121 | # sync.pull(filepath) 122 | -------------------------------------------------------------------------------- /adbutils/__main__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | """ 4 | python -m apkutils COMMAND 5 | 6 | Commands: 7 | install Install apk to device 8 | """ 9 | 10 | from __future__ import absolute_import 11 | 12 | import argparse 13 | import datetime 14 | import functools 15 | import json 16 | import os 17 | import re 18 | import shutil 19 | import subprocess 20 | import sys 21 | import zipfile 22 | 23 | import requests 24 | 25 | import adbutils 26 | from adbutils import adb as adbclient 27 | from adbutils._utils import ReadProgress, current_ip, APKReader 28 | 29 | 30 | def _setup_minicap(d: adbutils.AdbDevice): 31 | def cache_download(url, dst): 32 | if os.path.exists(dst): 33 | print("Use cached", dst) 34 | return 35 | print("Download {} from {}".format(dst, url)) 36 | resp = requests.get(url, stream=True) 37 | resp.raise_for_status() 38 | length = int(resp.headers.get("Content-Length", 0)) 39 | r = ReadProgress(resp.raw, length) 40 | with open(dst + ".cached", "wb") as f: 41 | shutil.copyfileobj(r, f) 42 | shutil.move(dst + ".cached", dst) 43 | 44 | def push_zipfile(path: str, 45 | dest: str, 46 | mode=0o755, 47 | zipfile_path: str = "vendor/stf-binaries-master.zip"): 48 | """ push minicap and minitouch from zip """ 49 | with zipfile.ZipFile(zipfile_path) as z: 50 | if path not in z.namelist(): 51 | print("WARNING: stf stuff %s not found", path) 52 | return 53 | with z.open(path) as f: 54 | d.sync.push(f, dest, mode) 55 | 56 | zipfile_path = "stf-binaries.zip" 57 | cache_download("https://github.com/openatx/stf-binaries/archive/0.2.zip", 58 | zipfile_path) 59 | zip_folder = "stf-binaries-0.2" 60 | 61 | sdk = d.getprop("ro.build.version.sdk") # eg 26 62 | abi = d.getprop('ro.product.cpu.abi') # eg arm64-v8a 63 | abis = (d.getprop('ro.product.cpu.abilist').strip() or abi).split(",") 64 | # return 65 | print("sdk: %s, abi: %s, support-abis: %s" % (sdk, abi, ','.join(abis))) 66 | print("Push minicap+minicap.so to device") 67 | prefix = zip_folder + "/node_modules/minicap-prebuilt/prebuilt/" 68 | push_zipfile(prefix + abi + "/lib/android-" + sdk + "/minicap.so", 69 | "/data/local/tmp/minicap.so", 0o644, zipfile_path) 70 | push_zipfile(prefix + abi + "/bin/minicap", "/data/local/tmp/minicap", 71 | 0o0755, zipfile_path) 72 | 73 | print("Push minitouch to device") 74 | prefix = zip_folder + "/node_modules/minitouch-prebuilt/prebuilt/" 75 | push_zipfile(prefix + abi + "/bin/minitouch", "/data/local/tmp/minitouch", 76 | 0o0755, zipfile_path) 77 | 78 | # check if minicap installed 79 | output = d.shell( 80 | ["LD_LIBRARY_PATH=/data/local/tmp", "/data/local/tmp/minicap", "-i"]) 81 | print(output) 82 | print("If you see JSON output, it means minicap installed successfully") 83 | 84 | 85 | def main(): 86 | parser = argparse.ArgumentParser() 87 | # formatter_class=argparse.ArgumentDefaultsHelpFormatter) 88 | 89 | parser.add_argument("-s", "--serial", help="device serial number") 90 | parser.add_argument("-V", 91 | "--server-version", 92 | action="store_true", 93 | help="show adb server version") 94 | parser.add_argument("-l", 95 | "--list", 96 | action="store_true", 97 | help="list devices") 98 | parser.add_argument("--list-extended", 99 | action="store_true", 100 | help="list devices with props (overrides --list)") 101 | parser.add_argument("-i", 102 | "--install", 103 | help="install from local apk or url") 104 | parser.add_argument( 105 | "--install-confirm", 106 | action="store_true", 107 | help="auto confirm when install (based on uiautomator2)") 108 | parser.add_argument("-u", "--uninstall", help="uninstall apk") 109 | parser.add_argument("-L", "--launch", action="store_true", help="launch after install") 110 | parser.add_argument("--qrcode", help="show qrcode of the specified file") 111 | parser.add_argument("--parse", type=str, help="parse package info from local file or url") 112 | parser.add_argument("--clear", 113 | action="store_true", 114 | help="clear all data when uninstall") 115 | parser.add_argument("--list-packages", 116 | action="store_true", 117 | help="list packages installed") 118 | parser.add_argument("--current", action="store_true", help="show current package info") 119 | parser.add_argument("-p", 120 | "--package", 121 | help="show package info in json format") 122 | parser.add_argument("--grep", help="filter matched package names") 123 | parser.add_argument("--connect", type=str, help="connect remote device") 124 | parser.add_argument("--shell", 125 | action="store_true", 126 | help="run shell command") 127 | parser.add_argument("--minicap", 128 | action="store_true", 129 | help="install minicap and minitouch to device") 130 | parser.add_argument("--screenshot", type=str, help="take screenshot") 131 | parser.add_argument("-b", "--browser", help="open browser in device") 132 | parser.add_argument( 133 | "--push", 134 | help= 135 | "push local to remote, arg is colon seperated, eg some.txt:/sdcard/s.txt" 136 | ) 137 | parser.add_argument( 138 | "--pull", 139 | help="push local to remote, arg is colon seperated, eg /sdcard/some.txt" 140 | ) 141 | parser.add_argument("--dump-info", action="store_true", help="dump info for developer") 142 | parser.add_argument("--track", action="store_true", help="trace device status") 143 | parser.add_argument("args", nargs="*", help="arguments") 144 | 145 | args = parser.parse_args() 146 | 147 | if args.connect: 148 | adbclient.connect(args.connect) 149 | return 150 | 151 | if args.server_version: 152 | print("ADB Server version: {}".format(adbclient.server_version())) 153 | return 154 | 155 | if args.list_extended: 156 | rows = [] 157 | for info in adbclient.list(extended=True): 158 | rows.append([info.serial, " ".join([k+":"+v for (k,v) in info.tags.items()])]) 159 | lens = [] 160 | for col in zip(*rows): 161 | lens.append(max([len(v) for v in col])) 162 | format = " ".join(["{:<" + str(l) + "}" for l in lens]) 163 | for row in rows: 164 | print(format.format(*row)) 165 | return 166 | 167 | if args.list: 168 | rows = [] 169 | for d in adbclient.device_list(): 170 | rows.append([d.serial, d.shell("getprop ro.product.model")]) 171 | lens = [] 172 | for col in zip(*rows): 173 | lens.append(max([len(v) for v in col])) 174 | format = " ".join(["{:<" + str(l) + "}" for l in lens]) 175 | for row in rows: 176 | print(format.format(*row)) 177 | return 178 | 179 | if args.qrcode: 180 | from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer 181 | 182 | filename = args.qrcode 183 | port = 8000 184 | url = "http://%s:%d/%s" % (current_ip(), port, filename) 185 | print("File URL:", url) 186 | try: 187 | import qrcode 188 | qr = qrcode.QRCode(border=2) 189 | qr.add_data(url) 190 | qr.print_ascii(tty=True) 191 | except ImportError: 192 | print( 193 | "In order to show QRCode, you need install with: pip3 install qrcode" 194 | ) 195 | 196 | httpd = ThreadingHTTPServer(('', port), SimpleHTTPRequestHandler) 197 | httpd.serve_forever() 198 | return 199 | 200 | if args.dump_info: 201 | print("==== ADB Info ====") 202 | print("Path:", adbutils.adb_path()) 203 | print("Server version:", adbclient.server_version()) 204 | print("") 205 | print(">> List of devices attached") 206 | for d in adbclient.device_list(): 207 | print("-", d.serial, d.prop.name, d.prop.model) 208 | return 209 | 210 | if args.track: 211 | for event in adbclient.track_devices(): 212 | asctime = datetime.datetime.now().strftime("%H:%M:%S.%f") 213 | print("{} {} -> {}".format(asctime[:-3], event.serial, event.status)) 214 | return 215 | 216 | elif args.parse: 217 | uri = args.parse 218 | 219 | fp = None 220 | if re.match(r"^https?://", uri): 221 | try: 222 | import httpio 223 | except ImportError: 224 | retcode = subprocess.call([sys.executable, '-m', 'pip', 'install', '-U', 'httpio']) 225 | assert retcode == 0 226 | import httpio 227 | fp = httpio.open(uri, block_size=-1) 228 | else: 229 | assert os.path.isfile(uri) 230 | fp = open(uri, 'rb') 231 | try: 232 | ar = APKReader(fp) 233 | ar.dump_info() 234 | finally: 235 | fp.close() 236 | return 237 | 238 | ## Device operation 239 | d = adbclient.device(args.serial) 240 | 241 | if args.shell: 242 | output = d.shell(args.args) 243 | print(output) 244 | return 245 | 246 | if args.install: 247 | def _callback(event_name: str, ud): 248 | name = "_INSTALL_" 249 | if event_name == "BEFORE_INSTALL": 250 | print("== Enable popup window watcher") 251 | ud.press("home") 252 | ud.watcher(name).when("允许").click() 253 | ud.watcher(name).when("继续安装").click() 254 | ud.watcher(name).when("安装").click() 255 | ud.watcher.start() 256 | elif event_name == "FINALLY": 257 | print("== Stop popup window watcher") 258 | ud.watcher.remove(name) 259 | ud.watcher.stop() 260 | 261 | if args.install_confirm: 262 | import uiautomator2 as u2 263 | ud = u2.connect_usb(args.serial) 264 | _callback = functools.partial(_callback, ud=ud) 265 | else: 266 | _callback = None 267 | 268 | d.install(args.install, nolaunch=not args.launch, uninstall=True, callback=_callback) 269 | 270 | elif args.uninstall: 271 | d.uninstall(args.uninstall) 272 | 273 | elif args.list_packages: 274 | patten = re.compile(args.grep or ".*") 275 | for p in d.list_packages(): 276 | if patten.search(p): 277 | print(p) 278 | 279 | elif args.screenshot: 280 | if args.minicap: 281 | def adb_shell(cmd: list): 282 | print("Run:", " ".join(["adb", "shell"] + cmd)) 283 | return d.shell(cmd).strip() 284 | json_output = adb_shell([ 285 | "LD_LIBRARY_PATH=/data/local/tmp", "/data/local/tmp/minicap", 286 | "-i", "2&>/dev/null" 287 | ]) 288 | if not json_output.startswith("{"): 289 | raise RuntimeError("Invalid json format", json_output) 290 | data = json.loads(json_output) 291 | 292 | w, h, r = data["width"], data["height"], data["rotation"] 293 | d.shell([ 294 | "LD_LIBRARY_PATH=/data/local/tmp", "/data/local/tmp/minicap", 295 | "-P", "{0}x{1}@{0}x{1}/{2}".format(w, h, r), "-s", 296 | ">/sdcard/minicap.jpg" 297 | ]) 298 | d.sync.pull("/sdcard/minicap.jpg", args.screenshot) 299 | else: 300 | remote_tmp_path = "/data/local/tmp/screenshot.png" 301 | d.shell(["rm", remote_tmp_path]) 302 | d.shell(["screencap", "-p", remote_tmp_path]) 303 | d.sync.pull(remote_tmp_path, args.screenshot) 304 | 305 | elif args.minicap: # without args.screenshot 306 | _setup_minicap(d) 307 | 308 | elif args.push: 309 | local, remote = args.push.split(":", 1) 310 | length = os.stat(local).st_size 311 | with open(local, "rb") as fd: 312 | r = ReadProgress(fd, length) 313 | d.sync.push(r, remote, filesize=length) 314 | 315 | elif args.pull: 316 | remote_path = args.pull 317 | target_path = os.path.basename(remote_path) 318 | finfo = d.sync.stat(args.pull) 319 | 320 | if finfo.mode == 0 and finfo.size == 0: 321 | sys.exit(f"remote file '{remote_path}' does not exist") 322 | 323 | with open(target_path, "wb") as fd: 324 | bytes_so_far = 0 325 | for chunk in d.sync.iter_content(remote_path): 326 | fd.write(chunk) 327 | bytes_so_far += len(chunk) 328 | percent = bytes_so_far / finfo.size * 100 if finfo.size != 0 else 100.0 329 | print( 330 | f"\rDownload to {target_path} ... [{bytes_so_far} / {finfo.size}] %.1f %%" 331 | % percent, 332 | end="", 333 | flush=True) 334 | print(f"{remote_path} pulled to {target_path}") 335 | 336 | elif args.browser: 337 | d.open_browser(args.browser) 338 | 339 | elif args.current: 340 | package_name = d.app_current().package 341 | info = d.app_info(package_name) 342 | print(json.dumps(info, indent=4, default=str)) 343 | 344 | elif args.package: 345 | info = d.app_info(args.package) 346 | print(json.dumps(info, indent=4, default=str)) 347 | 348 | 349 | 350 | if __name__ == "__main__": 351 | main() 352 | -------------------------------------------------------------------------------- /adbutils/_adb.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Fri May 06 2022 10:58:29 by codeskyblue 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | import io 10 | import os 11 | import platform 12 | import socket 13 | import subprocess 14 | import typing 15 | from typing import Iterator, List, Optional, Union 16 | 17 | from deprecation import deprecated 18 | 19 | from adbutils._utils import adb_path 20 | from adbutils.errors import AdbConnectionError, AdbError, AdbTimeout 21 | 22 | from adbutils._proto import * 23 | from adbutils._version import __version__ 24 | 25 | _OKAY = b"OKAY" 26 | _FAIL = b"FAIL" 27 | 28 | 29 | def _check_server(host: str, port: int) -> bool: 30 | """ Returns if server is running """ 31 | s = socket.socket() 32 | try: 33 | s.settimeout(.1) 34 | s.connect((host, port)) 35 | return True 36 | except (socket.timeout, socket.error) as e: 37 | return False 38 | finally: 39 | s.close() 40 | 41 | 42 | class AdbConnection(object): 43 | def __init__(self, host: str, port: int): 44 | self.__host = host 45 | self.__port = port 46 | self.__conn = self._safe_connect() 47 | 48 | def _create_socket(self): 49 | adb_host = self.__host 50 | adb_port = self.__port 51 | s = socket.socket() 52 | s.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) # Set TCP keepalive 53 | sys_platform = platform.system() 54 | if sys_platform == "Linux": 55 | # Only set these options on Linux 56 | s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 10) 57 | s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3) 58 | s.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10) 59 | # On Darwin (macOS) and Windows, skip these options 60 | try: 61 | s.settimeout(3) # prevent socket hang 62 | s.connect((adb_host, adb_port)) 63 | s.settimeout(None) 64 | return s 65 | except socket.timeout as e: 66 | raise AdbTimeout("connect to adb server timeout") # windows raise timeout, mac raise connection error 67 | except socket.error as e: 68 | raise AdbConnectionError("connect to adb server failed: %s" % e) 69 | 70 | def _safe_connect(self): 71 | try: 72 | return self._create_socket() 73 | except AdbTimeout: 74 | pass 75 | except AdbConnectionError: 76 | pass 77 | flags = subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0 78 | subprocess.run([adb_path(), "start-server"], timeout=20.0, creationflags=flags) # 20s should enough for adb start 79 | return self._create_socket() 80 | 81 | @property 82 | def closed(self) -> bool: 83 | return self.__conn is None 84 | 85 | # https://github.com/openatx/adbutils/issues/169 86 | # no need to close in __del__ 87 | # def __del__(self): 88 | # self.close() 89 | 90 | def close(self): 91 | if self.__conn is None: 92 | return 93 | try: 94 | self.__conn.shutdown(socket.SHUT_RDWR) # send FIN 95 | except OSError: 96 | pass 97 | self.__conn.close() 98 | self.__conn = None 99 | 100 | def __enter__(self): 101 | return self 102 | 103 | def __exit__(self, exc_type, exc, traceback): 104 | self.close() 105 | 106 | @property 107 | def conn(self) -> socket.socket: 108 | if self.__conn is None: 109 | raise AdbError("Connection is closed") 110 | return self.__conn 111 | 112 | def send(self, data: bytes) -> int: 113 | return self.conn.send(data) 114 | 115 | def read(self, n: int) -> bytes: 116 | try: 117 | return self._read_fully(n) 118 | except socket.timeout: 119 | raise AdbTimeout("adb read timeout") 120 | 121 | def read_uint32(self) -> int: 122 | data = self.read(4) 123 | return int.from_bytes(data, "little") 124 | 125 | def _read_fully(self, n: int) -> bytes: 126 | t = n 127 | buffer = b'' 128 | while t > 0: 129 | chunk = self.conn.recv(t) 130 | if not chunk: 131 | break 132 | buffer += chunk 133 | t = n - len(buffer) 134 | return buffer 135 | 136 | def read_exact(self, n: int) -> bytes: 137 | try: 138 | data = self._read_fully(n) 139 | except socket.timeout: 140 | raise AdbTimeout("adb read timeout") 141 | if len(data) < n: 142 | raise EOFError(f"Expected {n} bytes, got {len(data)}") 143 | return data 144 | 145 | def send_command(self, cmd: str): 146 | cmd_bytes = cmd.encode("utf-8") 147 | self.conn.send("{:04x}".format(len(cmd_bytes)).encode("utf-8") + cmd_bytes) 148 | 149 | def read_string(self, n: int) -> str: 150 | data = self.read(n).decode("utf-8", errors="replace") 151 | return data 152 | 153 | def read_string_block(self) -> str: 154 | """ 155 | Raises: 156 | AdbError 157 | """ 158 | length = self.read_string(4) 159 | if not length: 160 | raise AdbError("connection closed") 161 | size = int(length, 16) 162 | return self.read_string(size) 163 | 164 | def read_until_close(self, encoding: str | None = "utf-8") -> Union[str, bytes]: 165 | """ 166 | read until connection close 167 | :param encoding: default utf-8, if pass None, return bytes 168 | """ 169 | content = b"" 170 | while True: 171 | chunk = self.read(4096) 172 | if not chunk: 173 | break 174 | content += chunk 175 | return content.decode(encoding, errors='replace') if encoding else content 176 | 177 | def check_okay(self): 178 | data = self.read(4) 179 | if data == _FAIL: 180 | raise AdbError(self.read_string_block()) 181 | elif data == _OKAY: 182 | return 183 | raise AdbError("Unknown data: %r" % data) 184 | 185 | 186 | class BaseClient(object): 187 | def __init__( 188 | self, 189 | host: Optional[str] = None, 190 | port: Optional[int] = None, 191 | socket_timeout: Optional[float] = None, 192 | ): 193 | """ 194 | Args: 195 | host (str): default value from env:ANDROID_ADB_SERVER_HOST 196 | port (int): default value from env:ANDROID_ADB_SERVER_PORT 197 | """ 198 | if not host: 199 | host = os.environ.get("ANDROID_ADB_SERVER_HOST", "127.0.0.1") 200 | if not port: 201 | port = int(os.environ.get("ANDROID_ADB_SERVER_PORT", 5037)) 202 | self.__host = host 203 | self.__port = port 204 | self.__socket_timeout = socket_timeout 205 | 206 | @property 207 | def host(self) -> str: 208 | return self.__host 209 | 210 | @property 211 | def port(self) -> int: 212 | return self.__port 213 | 214 | def make_connection(self, timeout: Optional[float] = None) -> AdbConnection: 215 | """ connect to adb server 216 | 217 | Raises: 218 | AdbTimeout 219 | """ 220 | timeout = timeout or self.__socket_timeout 221 | try: 222 | _conn = AdbConnection(self.__host, self.__port) 223 | if timeout: 224 | _conn.conn.settimeout(timeout) 225 | return _conn 226 | except TimeoutError: 227 | raise AdbTimeout("connect to adb server timeout") 228 | 229 | def server_version(self): 230 | """ 40 will match 1.0.40 231 | Returns: 232 | int 233 | """ 234 | with self.make_connection() as c: 235 | c.send_command("host:version") 236 | c.check_okay() 237 | return int(c.read_string_block(), 16) 238 | 239 | def server_kill(self): 240 | """ 241 | adb kill-server 242 | 243 | Send host:kill if adb-server is alive 244 | """ 245 | if _check_server(self.__host, self.__port): 246 | with self.make_connection() as c: 247 | c.send_command("host:kill") 248 | c.check_okay() 249 | 250 | def wait_for(self, serial: str = None, transport: str = 'any', state: str = "device", timeout: float=60): 251 | """ Same as wait-for-TRANSPORT-STATE 252 | Args: 253 | serial (str): device serial [default None] 254 | transport (str): {any,usb,local} [default any] 255 | state (str): {device,recovery,rescue,sideload,bootloader,disconnect} [default device] 256 | timeout (float): max wait time [default 60] 257 | 258 | Raises: 259 | AdbError, AdbTimeout 260 | """ 261 | with self.make_connection(timeout=timeout) as c: 262 | cmds = [] 263 | if serial: 264 | cmds.extend(['host-serial', serial]) 265 | else: 266 | cmds.append('host') 267 | cmds.append("wait-for-" + transport + "-" + state) 268 | c.send_command(":".join(cmds)) 269 | c.check_okay() 270 | c.check_okay() 271 | 272 | # def reconnect(self, addr: str, timeout: float=None) -> str: 273 | # """ this function is not same as adb reconnect 274 | # actually the behavior is same as 275 | # - adb disconnect x.x.x.x 276 | # - adb connect x.x.x.x 277 | # """ 278 | # self.disconnect(addr) 279 | # return self.connect(addr, timeout=timeout) 280 | 281 | def connect(self, addr: str, timeout: float=None) -> str: 282 | """ adb connect $addr 283 | Args: 284 | addr (str): adb remote address [eg: 191.168.0.1:5555] 285 | timeout (float): connect timeout 286 | 287 | Returns: 288 | content adb server returns 289 | 290 | Raises: 291 | AdbTimeout 292 | 293 | Example returns: 294 | - "already connected to 192.168.190.101:5555" 295 | - "unable to connect to 192.168.190.101:5551" 296 | - "failed to connect to '1.2.3.4:4567': Operation timed out" 297 | """ 298 | with self.make_connection(timeout=timeout) as c: 299 | c.send_command("host:connect:" + addr) 300 | c.check_okay() 301 | return c.read_string_block() 302 | 303 | def disconnect(self, addr: str, raise_error: bool=False) -> str: 304 | """ adb disconnect $addr 305 | Returns: 306 | content adb server returns 307 | 308 | Raises: 309 | when raise_error set to True 310 | AdbError("error: no such device '1.2.3.4:5678') 311 | 312 | Example returns: 313 | - "disconnected 192.168.190.101:5555" 314 | """ 315 | try: 316 | with self.make_connection() as c: 317 | c.send_command("host:disconnect:" + addr) 318 | c.check_okay() 319 | return c.read_string_block() 320 | except AdbError: 321 | if raise_error: 322 | raise 323 | 324 | def track_devices(self) -> Iterator[DeviceEvent]: 325 | """ 326 | Report device state when changes 327 | 328 | Args: 329 | limit_status: eg, ['device', 'offline'], empty means all status 330 | 331 | Returns: 332 | Iterator[DeviceEvent], DeviceEvent.status can be one of ['device', 'offline', 'unauthorized', 'absent'] 333 | 334 | Raises: 335 | AdbError when adb-server was killed 336 | """ 337 | orig_devices = [] 338 | 339 | with self.make_connection() as c: 340 | c.send_command("host:track-devices") 341 | c.check_okay() 342 | while True: 343 | output = c.read_string_block() 344 | curr_devices = self._output2devices(output) 345 | for event in self._diff_devices(orig_devices, curr_devices): 346 | yield event 347 | orig_devices = curr_devices 348 | 349 | def _output2devices(self, output: str): 350 | devices = [] 351 | for line in output.splitlines(): 352 | fields = line.strip().split("\t", maxsplit=1) 353 | if len(fields) != 2: 354 | continue 355 | serial, status = fields 356 | devices.append(DeviceEvent(None, serial, status)) 357 | return devices 358 | 359 | def _diff_devices(self, orig: typing.List[DeviceEvent], curr: typing.List[DeviceEvent]): 360 | for d in set(orig).difference(curr): 361 | yield DeviceEvent(False, d.serial, 'absent') 362 | for d in set(curr).difference(orig): 363 | yield DeviceEvent(True, d.serial, d.status) 364 | 365 | def forward_list(self, serial: Union[None, str] = None) -> List[ForwardItem]: 366 | with self.make_connection() as c: 367 | list_cmd = "host:list-forward" 368 | if serial: 369 | list_cmd = "host-serial:{}:list-forward".format(serial) 370 | c.send_command(list_cmd) 371 | c.check_okay() 372 | content = c.read_string_block() 373 | items = [] 374 | for line in content.splitlines(): 375 | parts = line.split() 376 | if len(parts) != 3: 377 | continue 378 | if serial and parts[0] != serial: 379 | continue 380 | items.append(ForwardItem(*parts)) 381 | return items 382 | 383 | def forward(self, serial, local, remote, norebind=False): 384 | """ 385 | Args: 386 | serial (str): device serial 387 | local, remote (str): tcp: or localabstract: 388 | norebind (bool): fail if already forwarded when set to true 389 | 390 | Raises: 391 | AdbError 392 | """ 393 | with self.make_connection() as c: 394 | cmds = ["host-serial", serial, "forward"] # host-prefix:forward:norebind:; 395 | if norebind: 396 | cmds.append("norebind") 397 | cmds.append(local + ";" + remote) 398 | c.send_command(":".join(cmds)) 399 | c.check_okay() 400 | 401 | @deprecated(deprecated_in="0.15.0", 402 | removed_in="1.0.0", 403 | details="use Device.reverse instead", 404 | current_version=__version__) 405 | def reverse(self, serial, remote, local, norebind=False): 406 | """ 407 | Args: 408 | serial (str): device serial 409 | remote, local (str): tcp: or localabstract: 410 | norebind (bool): fail if already reversed when set to true 411 | 412 | Raises: 413 | AdbError 414 | """ 415 | with self.make_connection() as c: 416 | c.send_command("host:transport:" + serial) 417 | c.check_okay() 418 | cmds = ['reverse:forward', remote + ";" + local] 419 | c.send_command(":".join(cmds)) 420 | c.check_okay() 421 | 422 | @deprecated(deprecated_in="0.15.0", 423 | removed_in="1.0.0", 424 | details="use Device.reverse_list instead", 425 | current_version=__version__) 426 | def reverse_list(self, serial: str) -> List[ReverseItem]: 427 | with self.make_connection() as c: 428 | c.send_command("host:transport:" + serial) 429 | c.check_okay() 430 | c.send_command("reverse:list-forward") 431 | c.check_okay() 432 | content = c.read_string_block() 433 | items = [] 434 | for line in content.splitlines(): 435 | parts = line.split() 436 | if len(parts) != 3: 437 | continue 438 | items.append(ReverseItem(*parts[1:])) 439 | return items 440 | 441 | 442 | 443 | -------------------------------------------------------------------------------- /adbutils/_deprecated.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Mon Apr 08 2024 12:21:30 by codeskyblue 5 | """ 6 | 7 | import abc 8 | import dataclasses 9 | from typing import Optional 10 | from adbutils._proto import AppInfo 11 | 12 | 13 | class AbstracctDevice(abc.ABC): 14 | @abc.abstractmethod 15 | def app_info(self, package_name: str) -> Optional[AppInfo]: 16 | pass 17 | 18 | 19 | class DeprecatedExtension(AbstracctDevice): 20 | def package_info(self, package_name: str) -> Optional[dict]: 21 | """deprecated method, use app_info instead.""" 22 | info = self.app_info(package_name) 23 | if info: 24 | return dataclasses.asdict(info) 25 | return None -------------------------------------------------------------------------------- /adbutils/_device.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """Created on Fri May 06 2022 10:33:39 by codeskyblue 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import io 9 | import logging 10 | import pathlib 11 | import re 12 | import socket 13 | import subprocess 14 | import threading 15 | import typing 16 | from typing import List, Optional, Union 17 | 18 | from PIL import Image, UnidentifiedImageError 19 | from deprecation import deprecated 20 | 21 | from adbutils._deprecated import DeprecatedExtension 22 | from adbutils._proto import ShellReturnRaw 23 | from adbutils.install import InstallExtension 24 | from adbutils.screenrecord import ScreenrecordExtension 25 | from adbutils.screenshot import ScreenshotExtesion 26 | 27 | from adbutils._adb import AdbConnection, BaseClient 28 | from adbutils._proto import * 29 | from adbutils._proto import StrOrPathLike 30 | from adbutils._utils import StopEvent, adb_path, get_free_port, list2cmdline 31 | from adbutils._version import __version__ 32 | from adbutils.errors import AdbError 33 | from adbutils.shell import ShellExtension 34 | from adbutils.sync import Sync 35 | 36 | 37 | _DEFAULT_SOCKET_TIMEOUT = 600 # 10 minutes 38 | 39 | logger = logging.getLogger(__name__) 40 | 41 | class BaseDevice: 42 | """Basic operation for a device""" 43 | 44 | def __init__( 45 | self, client: BaseClient, serial: Optional[str] = None, transport_id: Optional[int] = None 46 | ): 47 | """ 48 | Args: 49 | client (BaseClient): AdbClient instance 50 | serial (str): device serial 51 | transport_id (int): transport_id 52 | """ 53 | self._client = client 54 | self._serial = serial 55 | self._transport_id = transport_id 56 | self._properties = {} # store properties data 57 | self._features = {} 58 | 59 | if not serial and not transport_id: 60 | raise AdbError("serial, transport_id must set atleast one") 61 | 62 | self._prepare() 63 | 64 | def _prepare(self): 65 | """rewrite in sub class""" 66 | 67 | @property 68 | def serial(self) -> Optional[str]: 69 | return self._serial 70 | 71 | def open_transport( 72 | self, command: Optional[str] = None, timeout: Optional[float] = _DEFAULT_SOCKET_TIMEOUT 73 | ) -> AdbConnection: 74 | # connect has it own timeout 75 | c = self._client.make_connection(timeout=timeout) 76 | 77 | if command: 78 | if self._transport_id: 79 | c.send_command(f"host-transport-id:{self._transport_id}:{command}") 80 | c.check_okay() 81 | elif self._serial: 82 | c.send_command(f"host-serial:{self._serial}:{command}") 83 | c.check_okay() 84 | else: 85 | raise RuntimeError("should not reach here") 86 | else: 87 | if self._transport_id: 88 | c.send_command(f"host:transport-id:{self._transport_id}") 89 | c.check_okay() 90 | elif self._serial: 91 | # host:tport:serial:xxx is also fine, but receive 12 bytes 92 | # recv: 4f 4b 41 59 14 00 00 00 00 00 00 00 OKAY........ 93 | c.send_command(f"host:tport:serial:{self._serial}") 94 | c.check_okay() 95 | c.read(8) # skip 8 bytes 96 | else: 97 | raise RuntimeError("should not reach here") 98 | return c 99 | 100 | def _get_with_command(self, cmd: str) -> str: 101 | with self.open_transport(cmd) as c: 102 | return c.read_string_block() 103 | 104 | def get_state(self) -> str: 105 | """return device state {offline,bootloader,device}""" 106 | return self._get_with_command("get-state") 107 | 108 | def get_serialno(self) -> str: 109 | """return the real device id, not the connect serial""" 110 | return self._get_with_command("get-serialno") 111 | 112 | def get_devpath(self) -> str: 113 | """example return: usb:12345678Y""" 114 | return self._get_with_command("get-devpath") 115 | 116 | def get_features(self) -> str: 117 | """ 118 | Return example: 119 | 'abb_exec,fixed_push_symlink_timestamp,abb,stat_v2,apex,shell_v2,fixed_push_mkdir,cmd' 120 | """ 121 | features = self._get_with_command("features") 122 | self._features = set(features.split(',')) 123 | return features 124 | 125 | @property 126 | def info(self) -> dict: 127 | return { 128 | "serialno": self.get_serialno(), 129 | "devpath": self.get_devpath(), 130 | "state": self.get_state(), 131 | } 132 | 133 | def __repr__(self): 134 | return "AdbDevice(serial={})".format(self.serial) 135 | 136 | @property 137 | def sync(self) -> Sync: 138 | return Sync(self._client, self.serial) 139 | 140 | @property 141 | def prop(self) -> "Property": 142 | return Property(self) 143 | 144 | def adb_output(self, *args, **kwargs): 145 | """Run adb command use subprocess and get its content 146 | 147 | Returns: 148 | string of output 149 | 150 | Raises: 151 | EnvironmentError 152 | """ 153 | cmds = [adb_path(), "-s", self._serial] if self._serial else [adb_path()] 154 | cmds.extend(args) 155 | try: 156 | return subprocess.check_output( 157 | cmds, stdin=subprocess.DEVNULL, stderr=subprocess.STDOUT 158 | ).decode("utf-8") 159 | except subprocess.CalledProcessError as e: 160 | if kwargs.get("raise_error", True): 161 | raise EnvironmentError( 162 | "subprocess", cmds, e.output.decode("utf-8", errors="ignore") 163 | ) 164 | 165 | def __shell( 166 | self, 167 | cmdargs: str | list | tuple, 168 | stream: bool = False, 169 | timeout: Optional[float] = _DEFAULT_SOCKET_TIMEOUT, 170 | encoding: str | None = "utf-8", 171 | rstrip=False, 172 | shell_v2=False 173 | ) -> typing.Union[AdbConnection, ShellReturn]: 174 | if isinstance(cmdargs, (list, tuple)): 175 | cmdargs = list2cmdline(cmdargs) 176 | if stream: 177 | timeout = None 178 | c = self.open_transport(timeout=timeout) 179 | c.send_command(f"shell{',v2'*shell_v2}:" + cmdargs) 180 | c.check_okay() 181 | if stream: 182 | return c 183 | stdout, stderr, exit_code = "", None, -1 184 | if shell_v2: 185 | stdout, stderr, exit_code = c.read_shell_v2_protocol_until_close(encoding=encoding) 186 | else: 187 | stdout = c.read_until_close(encoding=encoding) 188 | c.close() 189 | if encoding: 190 | stdout = stdout.rstrip() if rstrip else stdout 191 | if stderr: 192 | stderr = stderr.rstrip() if rstrip else stderr 193 | return ShellReturn(command=cmdargs, returncode=exit_code, output=stdout, stderr=stderr) 194 | 195 | def shell( 196 | self, 197 | cmdargs: Union[str, list, tuple], 198 | stream: bool = False, 199 | timeout: Optional[float] = _DEFAULT_SOCKET_TIMEOUT, 200 | encoding: str | None = "utf-8", 201 | rstrip=True, 202 | ) -> typing.Union[AdbConnection, str, bytes]: 203 | """Run shell inside device and get it's content 204 | 205 | Args: 206 | rstrip (bool): strip the last empty line (Default: True) 207 | stream (bool): return stream instead of string output (Default: False) 208 | timeout (float): set shell timeout 209 | encoding (str): set output encoding (Default: utf-8), set None to make return bytes 210 | rstrip (bool): strip the last empty line, only work when encoding is set 211 | 212 | Returns: 213 | string of output when stream is False 214 | AdbConnection when stream is True 215 | 216 | Raises: 217 | AdbTimeout 218 | 219 | Examples: 220 | shell("ls -l") 221 | shell(["ls", "-l"]) 222 | shell("ls | grep data") 223 | """ 224 | if isinstance(cmdargs, (list, tuple)): 225 | cmdargs = list2cmdline(cmdargs) 226 | if stream: 227 | timeout = None 228 | c = self.open_transport(timeout=timeout) 229 | c.send_command("shell:" + cmdargs) 230 | c.check_okay() 231 | if stream: 232 | return c 233 | output = c.read_until_close(encoding=encoding) 234 | # https://github.com/openatx/uiautomator2/issues/998 235 | c.close() 236 | if encoding: 237 | return output.rstrip() if rstrip else output 238 | return output 239 | 240 | def shell2( 241 | self, 242 | cmdargs: Union[str, list, tuple], 243 | timeout: Optional[float] = _DEFAULT_SOCKET_TIMEOUT, 244 | encoding: str | None = "utf-8", 245 | rstrip=False, 246 | v2=False, 247 | ) -> Union[ShellReturn, ShellReturnRaw]: 248 | """ 249 | Run shell command with detail output 250 | Args: 251 | cmdargs (str | list | tuple): command args 252 | timeout (float): set shell timeout, seconds 253 | encoding (str): set output encoding (Default: utf-8), set None to make return bytes 254 | rstrip (bool): strip the last empty line, only work when encoding is set 255 | shell_v2 (bool): attempt to use the shell_v2 protocol (and fail if not supported by device) 256 | 257 | Returns: 258 | ShellOutput 259 | 260 | Raises: 261 | AdbTimeout 262 | """ 263 | if isinstance(cmdargs, (list, tuple)): 264 | cmdargs = list2cmdline(cmdargs) 265 | 266 | if v2: 267 | if not self._features: 268 | self._features = set(self.get_features().split(',')) 269 | if "shell_v2" not in self._features: 270 | v2 = False 271 | logger.warning("shell_v2 specified but not supported by device") 272 | 273 | if v2: 274 | result = self._shell_v2(cmdargs, timeout) 275 | else: 276 | result = self._shell_v1(cmdargs, timeout) 277 | 278 | if encoding: 279 | result = ShellReturn( 280 | command=result.command, 281 | returncode=result.returncode, 282 | output=result.output.decode(encoding, errors="replace"), 283 | stderr=result.stderr.decode(encoding, errors="replace"), 284 | stdout=result.stdout.decode(encoding, errors="replace"), 285 | ) 286 | if rstrip: 287 | result.output = result.output.rstrip() 288 | result.stderr = result.stderr.rstrip() 289 | result.stdout = result.stdout.rstrip() 290 | return result 291 | 292 | def _shell_v1(self, cmdargs: str, timeout: Optional[float] = _DEFAULT_SOCKET_TIMEOUT) -> ShellReturnRaw: 293 | assert isinstance(cmdargs, str) 294 | MAGIC = "X4EXIT:" 295 | newcmd = cmdargs + f"; echo {MAGIC}$?" 296 | output: bytes = self.shell(newcmd, timeout=timeout, encoding=None, rstrip=False) # type: ignore 297 | rindex = output.rfind(MAGIC.encode()) 298 | if rindex == -1: # normally will not possible 299 | raise AdbError("shell output invalid", newcmd, output) 300 | returncode = int(output[rindex + len(MAGIC) :]) 301 | output = output[:rindex] 302 | return ShellReturnRaw(command=cmdargs, returncode=returncode, output=output) 303 | 304 | def _shell_v2(self, cmdargs: str, timeout: Optional[float] = _DEFAULT_SOCKET_TIMEOUT) -> ShellReturnRaw: 305 | c = self.open_transport(timeout=timeout) 306 | c.send_command(f"shell,v2:{cmdargs}") 307 | c.check_okay() 308 | stdout_buffer = io.BytesIO() 309 | stderr_buffer = io.BytesIO() 310 | output_buffer = io.BytesIO() 311 | exit_code = 255 312 | 313 | while True: 314 | header = c.read_exact(5) 315 | msg_id = header[0] 316 | length = int.from_bytes(header[1:5], byteorder="little") 317 | if length == 0: 318 | continue 319 | 320 | data = c.read_exact(length) 321 | if msg_id == 1: 322 | stdout_buffer.write(data) 323 | output_buffer.write(data) 324 | elif msg_id == 2: 325 | stderr_buffer.write(data) 326 | output_buffer.write(data) 327 | elif msg_id == 3: 328 | exit_code = data[0] 329 | break 330 | return ShellReturnRaw( 331 | command=cmdargs, 332 | returncode=exit_code, 333 | output=output_buffer.getvalue(), 334 | stderr=stderr_buffer.getvalue(), 335 | stdout=stdout_buffer.getvalue(), 336 | ) 337 | 338 | def forward(self, local: str, remote: str, norebind: bool = False): 339 | self._client.forward(self._serial, local, remote, norebind) 340 | 341 | def forward_port(self, remote: Union[int, str]) -> int: 342 | """forward remote port to local random port""" 343 | if isinstance(remote, int): 344 | remote = "tcp:" + str(remote) 345 | for f in self.forward_list(): 346 | if ( 347 | f.serial == self._serial 348 | and f.remote == remote 349 | and f.local.startswith("tcp:") 350 | ): # yapf: disable 351 | return int(f.local[len("tcp:") :]) 352 | local_port = get_free_port() 353 | self.forward("tcp:" + str(local_port), remote) 354 | return local_port 355 | 356 | def forward_list(self) -> List[ForwardItem]: 357 | items = self._client.forward_list() 358 | return [item for item in items if item.serial == self._serial] 359 | 360 | def reverse(self, remote: str, local: str, norebind: bool = False): 361 | """ 362 | Args: 363 | serial (str): device serial 364 | remote, local (str): 365 | - tcp: 366 | - localabstract: 367 | - localreserved: 368 | - localfilesystem: 369 | norebind (bool): fail if already reversed when set to true 370 | 371 | Raises: 372 | AdbError 373 | """ 374 | with self.open_transport() as c: 375 | args = ["reverse:forward"] 376 | if norebind: 377 | args.append("norebind") 378 | args.append(remote + ";" + local) 379 | c.send_command(":".join(args)) 380 | c.check_okay() # this OKAY means message was received 381 | c.check_okay() # check reponse 382 | 383 | def reverse_list(self) -> List[ReverseItem]: 384 | with self.open_transport() as c: 385 | c.send_command("reverse:list-forward") 386 | c.check_okay() 387 | content = c.read_string_block() 388 | items = [] 389 | for line in content.splitlines(): 390 | parts = line.split() 391 | if len(parts) != 3: 392 | continue 393 | items.append(ReverseItem(*parts[1:])) 394 | return items 395 | 396 | def framebuffer(self) -> Image.Image: 397 | """Capture device screen and return PIL.Image object (Not very stable) 398 | 399 | Raises: 400 | NotImplementedError 401 | """ 402 | # Ref: https://android.googlesource.com/platform/system/core/+/android-cts-7.0_r18/adb/framebuffer_service.cpp 403 | # Ref: https://github.com/DeviceFarmer/adbkit/blob/c16081384ca34addbdab318bda3c76434b7538af/src/adb/command/host-transport/framebuffer.ts 404 | c = self.open_transport() 405 | c.send_command("framebuffer:") 406 | c.check_okay() 407 | 408 | version = c.read_uint32() 409 | if version == 16: 410 | raise NotImplementedError("Unsupported version 16") 411 | bpp = c.read_uint32() # bits per pixel 412 | if bpp != 24 and bpp != 32: 413 | raise NotImplementedError("Unsupported bpp(bits per pixel)", bpp) 414 | size = c.read_uint32() 415 | if size == 1: 416 | # FIXME: what is this? 417 | size = c.read_uint32() 418 | width = c.read_uint32() 419 | height = c.read_uint32() 420 | red_offset = c.read_uint32() 421 | red_length = c.read_uint32() # always 8 422 | blue_offset = c.read_uint32() 423 | blue_length = c.read_uint32() # always 8 424 | green_offset = c.read_uint32() 425 | green_length = c.read_uint32() # always 8 426 | alpha_offset = c.read_uint32() 427 | alpha_length = c.read_uint32() 428 | 429 | color_format = 'RGB' 430 | if blue_offset == 0: 431 | color_format = 'BGR' 432 | if bpp == 32 or alpha_length: 433 | color_format += 'A' 434 | 435 | if color_format != 'RGBA' and color_format != 'RGB': 436 | raise NotImplementedError("Unsupported color format") 437 | buffer = c.read(size) 438 | if len(buffer) != size: 439 | raise UnidentifiedImageError("framebuffer size not match", size, len(buffer)) 440 | image = Image.frombytes(color_format, (width, height), buffer) 441 | return image 442 | 443 | @deprecated(deprecated_in="2.6.0", removed_in="3.0.0", current_version=__version__, details="use sync.push instead") 444 | def push(self, local: str, remote: str): 445 | """ alias for sync.push """ 446 | return self.sync.push(local, remote) 447 | 448 | def create_connection( 449 | self, network: Network, address: Union[int, str] 450 | ) -> socket.socket: 451 | """ 452 | Used to connect a socket (unix of tcp) on the device 453 | 454 | Returns: 455 | socket object 456 | 457 | Raises: 458 | AssertionError, ValueError 459 | """ 460 | c = self.open_transport() 461 | if network == Network.TCP: 462 | assert isinstance(address, int) 463 | c.send_command("tcp:" + str(address)) 464 | c.check_okay() 465 | elif network in [Network.UNIX, Network.LOCAL_ABSTRACT]: 466 | assert isinstance(address, str) 467 | c.send_command("localabstract:" + address) 468 | c.check_okay() 469 | elif network in [ 470 | Network.LOCAL_FILESYSTEM, 471 | Network.LOCAL, 472 | Network.DEV, 473 | Network.LOCAL_RESERVED, 474 | ]: 475 | c.send_command(network + ":" + str(address)) 476 | c.check_okay() 477 | else: 478 | raise ValueError("Unsupported network type", network) 479 | return c.conn 480 | 481 | def root(self): 482 | """restart adbd as root 483 | 484 | Return example: 485 | cannot run as root in production builds 486 | """ 487 | # Ref: https://github.com/Swind/pure-python-adb/blob/master/ppadb/command/transport/__init__.py#L179 488 | with self.open_transport() as c: 489 | c.send_command("root:") 490 | c.check_okay() 491 | return c.read_until_close() 492 | 493 | def tcpip(self, port: int): 494 | """restart adbd listening on TCP on PORT 495 | 496 | Return example: 497 | restarting in TCP mode port: 5555 498 | """ 499 | with self.open_transport() as c: 500 | c.send_command("tcpip:" + str(port)) 501 | c.check_okay() 502 | return c.read_until_close() 503 | 504 | def logcat( 505 | self, 506 | file: StrOrPathLike = None, 507 | clear: bool = False, 508 | re_filter: typing.Union[str, re.Pattern] = None, 509 | command: str = "logcat -v time", 510 | ) -> StopEvent: 511 | """ 512 | Args: 513 | file (str): file path to save logcat 514 | clear (bool): clear logcat before start 515 | re_filter (str | re.Pattern): regex pattern to filter logcat 516 | command (str): logcat command, default is "logcat -v time" 517 | 518 | Example usage: 519 | >>> evt = device.logcat("logcat.txt", clear=True, re_filter=".*python.*") 520 | >>> time.sleep(10) 521 | >>> evt.stop() 522 | """ 523 | if re_filter: 524 | if isinstance(re_filter, str): 525 | re_filter = re.compile(re_filter) 526 | assert isinstance(re_filter, re.Pattern) 527 | 528 | if clear: 529 | self.shell("logcat --clear") 530 | 531 | def _filter_func(line: str) -> bool: 532 | if re_filter is None: 533 | return True 534 | return re_filter.search(line) is not None 535 | 536 | def _copy2file( 537 | stream: AdbConnection, 538 | fdst: typing.TextIO, 539 | event: StopEvent, 540 | filter_func: typing.Callable[[str], bool], 541 | ): 542 | try: 543 | fsrc = stream.conn.makefile("r", encoding="UTF-8", errors="replace") 544 | while not event.is_stopped(): 545 | line = fsrc.readline() 546 | if not line: 547 | break 548 | if filter_func(line): 549 | fdst.write(line) 550 | fdst.flush() 551 | finally: 552 | fsrc.close() 553 | stream.close() 554 | event.done() 555 | 556 | event = StopEvent() 557 | stream = self.shell(command, stream=True) 558 | fdst = pathlib.Path(file).open("w", encoding="UTF-8") 559 | threading.Thread( 560 | name="logcat", 561 | target=_copy2file, 562 | args=(stream, fdst, event, _filter_func), 563 | daemon=True, 564 | ).start() 565 | return event 566 | 567 | 568 | class Property: 569 | 570 | def __init__(self, d: BaseDevice): 571 | self._d = d 572 | 573 | def __str__(self): 574 | return f"product:{self.name} model:{self.model} device:{self.device}" 575 | 576 | def get(self, name: str, cache=True) -> str: 577 | if cache and name in self._d._properties: 578 | return self._d._properties[name] 579 | value = self._d._properties[name] = self._d.shell(["getprop", name]).strip() 580 | return value 581 | 582 | @property 583 | def name(self): 584 | return self.get("ro.product.name", cache=True) 585 | 586 | @property 587 | def model(self): 588 | return self.get("ro.product.model", cache=True) 589 | 590 | @property 591 | def device(self): 592 | return self.get("ro.product.device", cache=True) 593 | 594 | 595 | class AdbDevice( 596 | BaseDevice, 597 | ShellExtension, 598 | ScreenrecordExtension, 599 | ScreenshotExtesion, 600 | InstallExtension, 601 | DeprecatedExtension, 602 | ): 603 | """provide custom functions for some complex operations""" 604 | 605 | def __init__( 606 | self, client: BaseClient, serial: Optional[str] = None, transport_id: Optional[int] = None 607 | ): 608 | BaseDevice.__init__(self, client, serial, transport_id) 609 | ScreenrecordExtension.__init__(self) 610 | -------------------------------------------------------------------------------- /adbutils/_proto.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """Created on Fri May 06 2022 11:39:40 by codeskyblue 4 | """ 5 | from __future__ import annotations 6 | 7 | __all__ = [ 8 | "Network", "BrightnessMode", "DeviceEvent", "ForwardItem", "ReverseItem", "FileInfo", 9 | "WindowSize", "RunningAppInfo", "ShellReturn", "AdbDeviceInfo", "AppInfo", "BatteryInfo" 10 | ] 11 | 12 | import enum 13 | import datetime 14 | import pathlib 15 | from typing import List, NamedTuple, Optional, Union 16 | from dataclasses import dataclass, field 17 | 18 | 19 | class Network(str, enum.Enum): 20 | TCP = "tcp" 21 | UNIX = "unix" 22 | 23 | DEV = "dev" 24 | LOCAL = "local" 25 | LOCAL_RESERVED = "localreserved" 26 | LOCAL_FILESYSTEM = "localfilesystem" 27 | LOCAL_ABSTRACT = "localabstract" # same as UNIX 28 | 29 | 30 | class BrightnessMode(int, enum.Enum): 31 | AUTO = 1 32 | MANUAL = 0 33 | 34 | 35 | @dataclass(frozen=True) 36 | class DeviceEvent: 37 | present: bool 38 | serial: str 39 | status: str 40 | 41 | 42 | @dataclass 43 | class ForwardItem: 44 | serial: str 45 | local: str 46 | remote: str 47 | 48 | 49 | @dataclass 50 | class ReverseItem: 51 | remote: str 52 | local: str 53 | 54 | 55 | @dataclass 56 | class FileInfo: 57 | mode: int 58 | size: int 59 | mtime: datetime.datetime 60 | path: str 61 | 62 | 63 | @dataclass 64 | class AppInfo: 65 | package_name: str 66 | version_name: Optional[str] 67 | version_code: Optional[int] 68 | flags: Union[str, list] 69 | first_install_time: datetime.datetime 70 | last_update_time: datetime.datetime 71 | signature: str 72 | path: str 73 | sub_apk_paths: List[str] 74 | 75 | 76 | @dataclass 77 | class BatteryInfo: 78 | ac_powered: bool 79 | usb_powered: bool 80 | wireless_powered: Optional[bool] 81 | dock_powered: Optional[bool] 82 | max_charging_current: Optional[int] 83 | max_charging_voltage: Optional[int] 84 | charge_counter: Optional[int] 85 | status: Optional[int] 86 | health: Optional[int] 87 | present: Optional[bool] 88 | level: Optional[int] 89 | scale: Optional[int] 90 | voltage: Optional[int] # mV 91 | temperature: Optional[float] # e.g. 25.0 92 | technology: Optional[str] 93 | 94 | 95 | class WindowSize(NamedTuple): 96 | width: int 97 | height: int 98 | 99 | 100 | @dataclass 101 | class RunningAppInfo: 102 | package: str 103 | activity: str 104 | pid: int = 0 105 | 106 | 107 | @dataclass 108 | class ShellReturnRaw: 109 | command: str 110 | returncode: int 111 | stdout: bytes = b"" 112 | stderr: bytes = b"" 113 | output: bytes = b"" 114 | 115 | 116 | @dataclass 117 | class ShellReturn: 118 | command: str 119 | returncode: int 120 | output: str = "" 121 | stderr: str = "" 122 | stdout: str = "" 123 | 124 | 125 | @dataclass 126 | class AdbDeviceInfo: 127 | serial: str 128 | state: str 129 | tags: dict[str, str] = field(default_factory=dict) 130 | 131 | 132 | StrOrPathLike = Union[str, pathlib.Path] 133 | -------------------------------------------------------------------------------- /adbutils/_utils.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import importlib.resources 3 | import os 4 | import random 5 | import shlex 6 | import socket 7 | import subprocess 8 | import sys 9 | import tempfile 10 | import threading 11 | import time 12 | import typing 13 | import zipfile 14 | import typing 15 | import pathlib 16 | 17 | from shutil import which 18 | 19 | from adbutils.errors import AdbError 20 | 21 | 22 | MB = 1024 * 1024 23 | 24 | 25 | def append_path(base: typing.Union[str, pathlib.Path], addition: str) -> str: 26 | if isinstance(base, pathlib.Path): 27 | return (base / addition).as_posix() 28 | else: 29 | return base + '/' + addition if base[-1] != '/' else base + addition 30 | 31 | def humanize(n: int) -> str: 32 | return '%.1f MB' % (float(n) / MB) 33 | 34 | 35 | def is_port_in_use(port: int) -> bool: 36 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 37 | return s.connect_ex(('127.0.0.1', port)) == 0 38 | 39 | 40 | def get_free_port(): 41 | try: 42 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 43 | s.bind(('127.0.0.1', 0)) 44 | try: 45 | return s.getsockname()[1] 46 | finally: 47 | s.close() 48 | except OSError: 49 | # bind 0 will fail on Manjaro, fallback to random port 50 | # https://github.com/openatx/adbutils/issues/85 51 | for _ in range(20): 52 | port = random.randint(10000, 20000) 53 | if not is_port_in_use(port): 54 | return port 55 | raise AdbError("No free port found") 56 | 57 | 58 | def list2cmdline(args: typing.Union[list, tuple]): 59 | """ do not use subprocess.list2cmdline, use this instead 60 | 61 | Reason: 62 | subprocess.list2cmdline(['echo', '&']) --> "a &", but what I expect should be "a '&'" 63 | """ 64 | return ' '.join(map(shlex.quote, args)) 65 | 66 | 67 | def current_ip(): 68 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 69 | try: 70 | s.connect(("8.8.8.8", 80)) 71 | ip = s.getsockname()[0] 72 | return ip 73 | except OSError: 74 | return "127.0.0.1" 75 | finally: 76 | s.close() 77 | 78 | def _get_bin_dir(): 79 | if sys.version_info < (3, 9): 80 | context = importlib.resources.path("adbutils.binaries", "__init__.py") 81 | else: 82 | ref = importlib.resources.files("adbutils.binaries") / "__init__.py" 83 | context = importlib.resources.as_file(ref) 84 | with context as path: 85 | pass 86 | # Return the dir. We assume that the data files are on a normal dir on the fs. 87 | return str(path.parent) 88 | 89 | 90 | def adb_path(): 91 | # 0. check env: ADBUTILS_ADB_PATH 92 | if os.getenv("ADBUTILS_ADB_PATH"): 93 | return os.getenv("ADBUTILS_ADB_PATH") 94 | 95 | # 1. find in $PATH 96 | exe = which("adb") 97 | if exe and _is_valid_exe(exe): 98 | return exe 99 | 100 | # 2. use buildin adb 101 | bin_dir = _get_bin_dir() 102 | exe = os.path.join(bin_dir, "adb.exe" if os.name == 'nt' else 'adb') 103 | if os.path.isfile(exe) and _is_valid_exe(exe): 104 | return exe 105 | 106 | raise AdbError("No adb exe could be found. Install adb on your system") 107 | 108 | 109 | def _popen_kwargs(prevent_sigint=False): 110 | startupinfo = None 111 | preexec_fn = None 112 | creationflags = 0 113 | if sys.platform.startswith("win"): 114 | # Stops executable from flashing on Windows (see imageio/imageio-ffmpeg#22) 115 | startupinfo = subprocess.STARTUPINFO() 116 | startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW 117 | if prevent_sigint: 118 | # Prevent propagation of sigint (see imageio/imageio-ffmpeg#4) 119 | # https://stackoverflow.com/questions/5045771 120 | if sys.platform.startswith("win"): 121 | creationflags = 0x00000200 122 | else: 123 | preexec_fn = os.setpgrp # the _pre_exec does not seem to work 124 | return { 125 | "startupinfo": startupinfo, 126 | "creationflags": creationflags, 127 | "preexec_fn": preexec_fn, 128 | } 129 | 130 | 131 | def _is_valid_exe(exe: str): 132 | cmd = [exe, "version"] 133 | try: 134 | subprocess.check_call( 135 | cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, 136 | stderr=subprocess.STDOUT, **_popen_kwargs() 137 | ) 138 | return True 139 | except (OSError, ValueError, subprocess.CalledProcessError): 140 | return False 141 | 142 | 143 | class ReadProgress(): 144 | def __init__(self, r, total_size: int, source_path=None): 145 | """ 146 | Args: 147 | source_path (str): store read content to filepath 148 | """ 149 | self.r = r 150 | self.total = total_size 151 | self.copied = 0 152 | self.start_time = time.time() 153 | self.update_time = time.time() 154 | self.m = hashlib.md5() 155 | self._chunk_size = 0 156 | self._hash = '' 157 | self._tmpfd = None if source_path else tempfile.NamedTemporaryFile(suffix=".apk") 158 | self._filepath = source_path 159 | 160 | def update(self, chunk: bytes): 161 | chunk_size = len(chunk) 162 | self.m.update(chunk) 163 | if chunk_size == 0: 164 | self._hash = self.m.hexdigest() 165 | self.copied += chunk_size 166 | self._chunk_size += chunk_size 167 | 168 | if self.total: 169 | percent = float(self.copied) / self.total * 100 170 | else: 171 | percent = 0.0 if chunk_size else 100.0 172 | 173 | p = int(percent) 174 | duration = time.time() - self.update_time 175 | if p == 100.0 or duration > 1.0: 176 | if duration: 177 | speed = humanize(self._chunk_size / duration) + "/s" 178 | else: 179 | copytime = max(0.1, time.time() - self.start_time) 180 | speed = humanize(self.copied / copytime) + "/s" 181 | 182 | self.update_time = time.time() 183 | self._chunk_size = 0 184 | 185 | copysize = humanize(self.copied) 186 | totalsize = humanize(self.total) 187 | if sys.stdout.isatty(): 188 | print("{:.1f}%\t{} [{}/{}]".format(percent, speed, copysize, 189 | totalsize)) 190 | 191 | def read(self, n: int) -> bytes: 192 | chunk = self.r.read(n) 193 | self.update(chunk) 194 | if self._tmpfd: 195 | self._tmpfd.write(chunk) 196 | return chunk 197 | 198 | def filepath(self): 199 | if self._filepath: 200 | return self._filepath 201 | self._tmpfd.seek(0) 202 | return self._tmpfd.name 203 | 204 | 205 | class APKReader: 206 | def __init__(self, fp: typing.BinaryIO): 207 | self._fp = fp 208 | 209 | def dump_info(self): 210 | try: 211 | from apkutils import APK 212 | except ImportError: 213 | sys.exit("apkutils is not installed, please install it first") 214 | apk = APK.from_io(self._fp) 215 | activities = apk.get_main_activities() 216 | main_activity = activities[0] if activities else None 217 | package_name = apk.get_package_name() 218 | if main_activity and main_activity.find(".") == -1: 219 | main_activity = "." + main_activity 220 | 221 | print("package:", package_name) 222 | print("main-activity:", main_activity) 223 | print("version-name:", apk._version_name) 224 | print('version-code:', apk._version_code) 225 | 226 | 227 | class StopEvent: 228 | def __init__(self): 229 | self.__stop = threading.Event() 230 | self.__done = threading.Event() 231 | 232 | def stop(self, timeout=None): 233 | """ send stop signal and wait signal accepted 234 | 235 | Raises: 236 | TimeoutError 237 | """ 238 | self.__stop.set() 239 | if not self.__done.wait(timeout): 240 | raise TimeoutError("wait for stopped timeout", timeout) 241 | 242 | def stop_nowait(self): 243 | """ send stop signal """ 244 | self.__stop.set() 245 | 246 | def is_stopped(self) -> bool: 247 | return self.__stop.is_set() 248 | 249 | def done(self): 250 | """ for worker thread to notify stop signal accepted """ 251 | self.__done.set() 252 | 253 | def is_done(self) -> bool: 254 | """ check if background worker has stopped """ 255 | return self.__done.is_set() 256 | 257 | def reset(self): 258 | self.__stop.clear() 259 | self.__done.clear() 260 | 261 | 262 | def escape_special_characters(text: str) -> str: 263 | """ 264 | A helper that escape special characters 265 | 266 | Args: 267 | text: str 268 | """ 269 | escaped = text.translate( 270 | str.maketrans({ 271 | "-": r"\-", 272 | "+": r"\+", 273 | "[": r"\[", 274 | "]": r"\]", 275 | "(": r"\(", 276 | ")": r"\)", 277 | "{": r"\{", 278 | "}": r"\}", 279 | "\\": r"\\\\", 280 | "^": r"\^", 281 | "$": r"\$", 282 | "*": r"\*", 283 | ".": r"\.", 284 | ",": r"\,", 285 | ":": r"\:", 286 | "~": r"\~", 287 | ";": r"\;", 288 | ">": r"\>", 289 | "<": r"\<", 290 | "%": r"\%", 291 | "#": r"\#", 292 | "\'": r"\\'", 293 | "\"": r'\\"', 294 | "`": r"\`", 295 | "!": r"\!", 296 | "?": r"\?", 297 | "|": r"\|", 298 | "=": r"\=", 299 | "@": r"\@", 300 | "/": r"\/", 301 | "_": r"\_", 302 | " ": r"%s", # special 303 | "&": r"\&" 304 | })) 305 | return escaped 306 | -------------------------------------------------------------------------------- /adbutils/_version.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Fri May 06 2022 10:54:04 by codeskyblue 5 | """ 6 | 7 | 8 | from importlib.metadata import version, PackageNotFoundError 9 | 10 | try: 11 | __version__ = version("adbutils") 12 | except PackageNotFoundError: 13 | __version__ = "unknown" -------------------------------------------------------------------------------- /adbutils/binaries/README.md: -------------------------------------------------------------------------------- 1 | Binaries are dropped here by the release script. 2 | -------------------------------------------------------------------------------- /adbutils/binaries/__init__.py: -------------------------------------------------------------------------------- 1 | # Just here to make importlib.resources work -------------------------------------------------------------------------------- /adbutils/errors.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Fri May 06 2022 19:04:40 by codeskyblue 5 | """ 6 | 7 | __all__ = ['AdbError', 'AdbTimeout', 'AdbInstallError'] 8 | 9 | import re 10 | 11 | 12 | class AdbError(Exception): 13 | """ adb error """ 14 | 15 | 16 | class AdbTimeout(AdbError): 17 | """ timeout when communicate to adb-server """ 18 | 19 | 20 | class AdbConnectionError(AdbError): 21 | """ connection error """ 22 | 23 | 24 | class AdbInstallError(AdbError): 25 | def __init__(self, output: str): 26 | """ 27 | Errors examples: 28 | Failure [INSTALL_FAILED_ALREADY_EXISTS: Attempt to re-install io.appium.android.apis without first uninstalling.] 29 | Error: Failed to parse APK file: android.content.pm.PackageParser$PackageParserException: Failed to parse /data/local/tmp/tmp-29649242.apk 30 | 31 | Reference: https://github.com/mzlogin/awesome-adb 32 | """ 33 | m = re.search(r"Failure \[([\w_]+)", output) 34 | self.reason = m.group(1) if m else "Unknown" 35 | self.output = output 36 | 37 | def __str__(self): 38 | return self.output 39 | 40 | 41 | class AdbSyncError(AdbError): 42 | """ sync error """ -------------------------------------------------------------------------------- /adbutils/install.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Sun Apr 07 2024 20:07:44 by codeskyblue 5 | """ 6 | 7 | import abc 8 | import logging 9 | from pathlib import Path 10 | import re 11 | import tempfile 12 | import time 13 | import typing 14 | 15 | import requests 16 | from typing import Optional, Union 17 | from adbutils.errors import AdbInstallError 18 | from adbutils.sync import Sync 19 | from adbutils._utils import humanize, ReadProgress 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | class AbstractDevice(abc.ABC): 24 | @abc.abstractmethod 25 | def shell(self, cmd: str) -> str: 26 | pass 27 | 28 | @property 29 | @abc.abstractmethod 30 | def sync(self) -> Sync: 31 | pass 32 | 33 | @abc.abstractmethod 34 | def app_start(self, package_name: str, activity: Optional[str] = None): 35 | pass 36 | 37 | @abc.abstractmethod 38 | def uninstall(self, package_name: str): 39 | pass 40 | 41 | @abc.abstractmethod 42 | def install_remote(self, path: str, clean: bool = False, flags: list = ["-r", "-t"]): 43 | pass 44 | 45 | 46 | class InstallExtension(AbstractDevice): 47 | @staticmethod 48 | def download_apk(url: str, path: Path): 49 | """ 50 | Download apk file from url 51 | 52 | Args: 53 | url (str): The URL of the APK file to download. 54 | path (Path): The local file path where the APK will be saved. 55 | 56 | Raises: 57 | requests.exceptions.RequestException: If the download fails. 58 | """ 59 | try: 60 | response = requests.get(url, stream=True) 61 | response.raise_for_status() # Raise an error for HTTP errors 62 | 63 | # Write the content to the specified path 64 | with open(path, "wb") as file: 65 | for chunk in response.iter_content(chunk_size=10240): 66 | if chunk: # Filter out keep-alive chunks 67 | file.write(chunk) 68 | except requests.exceptions.RequestException as e: 69 | logger.warning(f"Failed to download APK from {url}: {e}") 70 | raise 71 | 72 | 73 | def install(self, 74 | path_or_url: Union[str, Path], 75 | nolaunch: bool = False, 76 | uninstall: bool = False, 77 | silent: bool = False, 78 | callback: typing.Callable[[str], None] = None, 79 | flags: list = ["-r", "-t"]): 80 | try: 81 | import apkutils 82 | has_apkutils = True 83 | except ImportError: 84 | logger.warning("apkutils is not installed, install it with 'pip install adbutils[apk]'") 85 | has_apkutils = False 86 | self._install(path_or_url, nolaunch, uninstall, silent, callback, flags, has_apkutils) 87 | 88 | def _install(self, 89 | path_or_url: Union[str, Path], 90 | nolaunch: bool = False, 91 | uninstall: bool = False, 92 | silent: bool = False, 93 | callback: typing.Callable[[str], None] = None, 94 | flags: list = ["-r", "-t"], 95 | has_apkutils: bool = True): 96 | """ 97 | Install APK to device 98 | 99 | Args: 100 | path_or_url: local path or http url 101 | nolaunch: do not launch app after install 102 | uninstall: uninstall app before install 103 | silent: disable log message print 104 | callback: only two event now: <"BEFORE_INSTALL" | "FINALLY"> 105 | flags (list): default ["-r", "-t"] 106 | has_apkutils: whether apkutils is installed 107 | 108 | Raises: 109 | AdbInstallError, BrokenPipeError 110 | """ 111 | def dprint(msg): 112 | if not silent: 113 | print(msg) 114 | 115 | if isinstance(path_or_url, str) and re.match(r"^https?://", path_or_url): 116 | tmpfile = tempfile.NamedTemporaryFile(suffix=".apk") 117 | self.download_apk(path_or_url, Path(tmpfile.name)) 118 | tmpfile.flush() 119 | tmpfile.seek(0) 120 | src_path = Path(tmpfile.name) 121 | dprint(f"download apk to {src_path}") 122 | else: 123 | src_path = Path(path_or_url) 124 | if not src_path.is_file(): 125 | raise FileNotFoundError(f"File or URL not found: {path_or_url}") 126 | 127 | package_name = None 128 | main_activity = None 129 | 130 | if has_apkutils: 131 | import apkutils 132 | with apkutils.APK.from_file(str(src_path)) as apk: 133 | activities = apk.get_main_activities() 134 | main_activity = activities[0] if activities else None 135 | package_name = apk.get_package_name() 136 | if main_activity and main_activity.find(".") == -1: 137 | main_activity = "." + main_activity 138 | 139 | dprint(f"APK packageName: {package_name}") 140 | dprint(f"APK mainActivity: {main_activity}") 141 | 142 | device_dst = f"/data/local/tmp/{package_name or 'unknown'}.apk" 143 | dprint(f'push apk to device: {device_dst}') 144 | self._push_apk(src_path, device_dst, show_progress=not silent) 145 | 146 | info = self.sync.stat(device_dst) 147 | apk_size = src_path.stat().st_size 148 | if not info.size == apk_size: 149 | AdbInstallError(f'pushed apk size not matched, expect {apk_size} got {info.size}') 150 | 151 | if uninstall and package_name: 152 | dprint(f"uninstall app: {package_name}") 153 | self.uninstall(package_name) 154 | 155 | dprint("install to android system ...") 156 | try: 157 | start = time.time() 158 | if callback: 159 | callback("BEFORE_INSTALL") 160 | 161 | self.install_remote(device_dst, clean=True, flags=flags) 162 | time_used = time.time() - start 163 | dprint(f"successfully installed, time used {time_used:.1f} seconds") 164 | if not nolaunch and package_name and main_activity: 165 | dprint("launch app: %s/%s" % (package_name, main_activity)) 166 | self.app_start(package_name, main_activity) 167 | 168 | except AdbInstallError as e: 169 | if package_name and e.reason in [ 170 | "INSTALL_FAILED_PERMISSION_MODEL_DOWNGRADE", 171 | "INSTALL_FAILED_UPDATE_INCOMPATIBLE", 172 | "INSTALL_FAILED_VERSION_DOWNGRADE" 173 | ]: 174 | dprint("uninstall %s because %s" % (package_name, e.reason)) 175 | self.uninstall(package_name) 176 | self.install_remote(device_dst, clean=True, flags=flags) 177 | dprint(f"successfully installed, time used {time.time() - start} seconds") 178 | if not nolaunch and main_activity: 179 | dprint(f"Launch app: {package_name}/{main_activity}") 180 | self.app_start(package_name, main_activity) 181 | else: 182 | # print to console 183 | print( 184 | "Failure " + e.reason + "\n" + 185 | "Remote apk is not removed. Manually install command:\n\t" 186 | + "adb shell pm install -r -t " + device_dst) 187 | raise 188 | finally: 189 | if callback: 190 | callback("FINALLY") 191 | 192 | def _push_apk(self, apk_path: Path, device_dst: str, show_progress: bool = True): 193 | """ 194 | Push APK file to device with progress indication. 195 | 196 | Args: 197 | apk_path (Path): Path to the APK file. 198 | device_dst (str): Destination path on the device. 199 | 200 | Returns: 201 | None 202 | """ 203 | start = time.time() 204 | length = apk_path.stat().st_size 205 | with apk_path.open("rb") as fd: 206 | if show_progress: 207 | r = ReadProgress(fd, length, source_path=str(apk_path)) 208 | self.sync.push(r, device_dst) 209 | else: 210 | self.sync.push(fd, device_dst) 211 | logger.info("Success pushed, time used %d seconds" % (time.time() - start)) 212 | -------------------------------------------------------------------------------- /adbutils/pidcat.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python -u 2 | ''' 3 | Copyright 2009, The Android Open Source Project 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | ''' 17 | 18 | # Script to highlight adb logcat output for console 19 | # Originally written by Jeff Sharkey, http://jsharkey.org/ 20 | # Piping detection and popen() added by other Android team members 21 | # Package filtering and output improvements by Jake Wharton, http://jakewharton.com 22 | 23 | import argparse 24 | import sys 25 | import re 26 | from subprocess import PIPE 27 | 28 | import adbutils 29 | 30 | __version__ = '2.1.0' 31 | 32 | # yapf: disable 33 | LOG_LEVELS = 'VDIWEF' 34 | LOG_LEVELS_MAP = dict([(LOG_LEVELS[i], i) for i in range(len(LOG_LEVELS))]) 35 | parser = argparse.ArgumentParser(description='Filter logcat by package name') 36 | parser.add_argument('package', nargs='*', help='Application package name(s)') 37 | parser.add_argument('-w', '--tag-width', metavar='N', dest='tag_width', type=int, default=23, help='Width of log tag') 38 | parser.add_argument('-l', '--min-level', dest='min_level', type=str, choices=LOG_LEVELS+LOG_LEVELS.lower(), default='V', help='Minimum level to be displayed [default: V]') 39 | parser.add_argument('--color-gc', dest='color_gc', action='store_true', help='Color garbage collection') 40 | parser.add_argument('--always-display-tags', dest='always_tags', action='store_true',help='Always display the tag name') 41 | parser.add_argument('--current', dest='current_app', action='store_true',help='Filter logcat by current running app') 42 | parser.add_argument('-s', '--serial', dest='device_serial', help='Device serial number (adb -s option)') 43 | parser.add_argument('-d', '--device', dest='use_device', action='store_true', help='Use first device for log input (adb -d option)') 44 | parser.add_argument('-e', '--emulator', dest='use_emulator', action='store_true', help='Use first emulator for log input (adb -e option)') 45 | parser.add_argument('-c', '--clear', dest='clear_logcat', action='store_true', help='Clear the entire log before running') 46 | parser.add_argument('-t', '--tag', dest='tag', action='append', help='Filter output by specified tag(s)') 47 | parser.add_argument('-i', '--ignore-tag', dest='ignored_tag', action='append', help='Filter output by ignoring specified tag(s)') 48 | parser.add_argument('-v', '--version', action='version', version='%(prog)s ' + __version__, help='Print the version number and exit') 49 | parser.add_argument('-a', '--all', dest='all', action='store_true', default=False, help='Print all log messages') 50 | # yapf: enable 51 | 52 | args = parser.parse_args() 53 | min_level = LOG_LEVELS_MAP[args.min_level.upper()] 54 | 55 | package = args.package 56 | adb_device = adbutils.adb.device(args.device_serial) 57 | 58 | 59 | if args.current_app: 60 | system_dump = adb_device.shell(["dumpsys", "activity", "activities"]) 61 | running_package_name = re.search(".*TaskRecord.*A[= ]([^ ^}]*)", 62 | system_dump).group(1) 63 | print("Current package:", running_package_name) 64 | package.append(running_package_name) 65 | 66 | if len(package) == 0: 67 | args.all = True 68 | 69 | # Store the names of packages for which to match all processes. 70 | catchall_package = list( 71 | filter(lambda package: package.find(":") == -1, package)) 72 | # Store the name of processes to match exactly. 73 | named_processes = list(filter(lambda package: package.find(":") != -1, 74 | package)) 75 | # Convert default process names from : (cli notation) to (android notation) in the exact names match group. 76 | named_processes = list( 77 | map( 78 | lambda package: package 79 | if package.find(":") != len(package) - 1 else package[:-1], 80 | named_processes)) 81 | 82 | header_size = args.tag_width + 1 + 3 + 1 # space, level, space 83 | 84 | width = -1 85 | try: 86 | # Get the current terminal width 87 | import fcntl, termios, struct 88 | h, width = struct.unpack( 89 | 'hh', fcntl.ioctl(0, termios.TIOCGWINSZ, struct.pack('hh', 0, 0))) 90 | except: 91 | pass 92 | 93 | BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = range(8) 94 | 95 | RESET = '\033[0m' 96 | 97 | 98 | def termcolor(fg=None, bg=None): 99 | codes = [] 100 | if fg is not None: codes.append('3%d' % fg) 101 | if bg is not None: codes.append('10%d' % bg) 102 | return '\033[%sm' % ';'.join(codes) if codes else '' 103 | 104 | 105 | def colorize(message, fg=None, bg=None): 106 | return termcolor(fg, bg) + message + RESET 107 | 108 | 109 | def indent_wrap(message): 110 | if width == -1: 111 | return message 112 | message = message.replace('\t', ' ') 113 | wrap_area = width - header_size 114 | messagebuf = '' 115 | current = 0 116 | while current < len(message): 117 | next = min(current + wrap_area, len(message)) 118 | messagebuf += message[current:next] 119 | if next < len(message): 120 | messagebuf += '\n' 121 | messagebuf += ' ' * header_size 122 | current = next 123 | return messagebuf 124 | 125 | 126 | LAST_USED = [RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN] 127 | KNOWN_TAGS = { 128 | 'dalvikvm': WHITE, 129 | 'Process': WHITE, 130 | 'ActivityManager': WHITE, 131 | 'ActivityThread': WHITE, 132 | 'AndroidRuntime': CYAN, 133 | 'jdwp': WHITE, 134 | 'StrictMode': WHITE, 135 | 'DEBUG': YELLOW, 136 | } 137 | 138 | 139 | def allocate_color(tag): 140 | # this will allocate a unique format for the given tag 141 | # since we dont have very many colors, we always keep track of the LRU 142 | if tag not in KNOWN_TAGS: 143 | KNOWN_TAGS[tag] = LAST_USED[0] 144 | color = KNOWN_TAGS[tag] 145 | if color in LAST_USED: 146 | LAST_USED.remove(color) 147 | LAST_USED.append(color) 148 | return color 149 | 150 | 151 | RULES = { 152 | # StrictMode policy violation; ~duration=319 ms: android.os.StrictMode$StrictModeDiskWriteViolation: policy=31 violation=1 153 | re.compile(r'^(StrictMode policy violation)(; ~duration=)(\d+ ms)'): 154 | r'%s\1%s\2%s\3%s' % (termcolor(RED), RESET, termcolor(YELLOW), RESET), 155 | } 156 | 157 | # Only enable GC coloring if the user opted-in 158 | if args.color_gc: 159 | # GC_CONCURRENT freed 3617K, 29% free 20525K/28648K, paused 4ms+5ms, total 85ms 160 | key = re.compile( 161 | r'^(GC_(?:CONCURRENT|FOR_M?ALLOC|EXTERNAL_ALLOC|EXPLICIT) )(freed >>>> ([a-zA-Z0-9._:]+) \[ userId:0 \| appId:(\d+) \]$') 182 | PID_KILL = re.compile(r'^Killing (\d+):([a-zA-Z0-9._:]+)/[^:]+: (.*)$') 183 | PID_LEAVE = re.compile(r'^No longer want ([a-zA-Z0-9._:]+) \(pid (\d+)\): .*$') 184 | PID_DEATH = re.compile(r'^Process ([a-zA-Z0-9._:]+) \(pid (\d+)\) has died.?$') 185 | LOG_LINE = re.compile(r'^([A-Z])/(.+?)\( *(\d+)\): (.*?)$') 186 | BUG_LINE = re.compile(r'.*nativeGetEnabledTags.*') 187 | BACKTRACE_LINE = re.compile(r'^#(.*?)pc\s(.*?)$') 188 | # yapf: enable 189 | 190 | 191 | adb_command = ["logcat", "-v", "brief"] 192 | 193 | # Clear log before starting logcat 194 | if args.clear_logcat: 195 | adb_clear_command = list(adb_command) 196 | adb_clear_command.append('-c') 197 | adb_device.shell(adb_clear_command) 198 | 199 | 200 | if sys.stdin.isatty(): 201 | stream = adb_device.shell(adb_command, stream=True) 202 | adb_stdout = stream.conn.makefile("r", encoding="utf-8", errors="replace") 203 | else: 204 | adb_stdout = sys.stdin 205 | pids = set() 206 | last_tag = None 207 | app_pid = None 208 | 209 | 210 | def match_packages(token): 211 | if len(package) == 0: 212 | return True 213 | if token in named_processes: 214 | return True 215 | index = token.find(':') 216 | return (token in catchall_package) if index == -1 else ( 217 | token[:index] in catchall_package) 218 | 219 | 220 | def parse_death(tag, message): 221 | if tag != 'ActivityManager': 222 | return None, None 223 | kill = PID_KILL.match(message) 224 | if kill: 225 | pid = kill.group(1) 226 | package_line = kill.group(2) 227 | if match_packages(package_line) and pid in pids: 228 | return pid, package_line 229 | leave = PID_LEAVE.match(message) 230 | if leave: 231 | pid = leave.group(2) 232 | package_line = leave.group(1) 233 | if match_packages(package_line) and pid in pids: 234 | return pid, package_line 235 | death = PID_DEATH.match(message) 236 | if death: 237 | pid = death.group(2) 238 | package_line = death.group(1) 239 | if match_packages(package_line) and pid in pids: 240 | return pid, package_line 241 | return None, None 242 | 243 | 244 | def parse_start_proc(line): 245 | start = PID_START_5_1.match(line) 246 | if start is not None: 247 | line_pid, line_package, target = start.groups() 248 | return line_package, target, line_pid, '', '' 249 | start = PID_START.match(line) 250 | if start is not None: 251 | line_package, target, line_pid, line_uid, line_gids = start.groups() 252 | return line_package, target, line_pid, line_uid, line_gids 253 | start = PID_START_DALVIK.match(line) 254 | if start is not None: 255 | line_pid, line_package, line_uid = start.groups() 256 | return line_package, '', line_pid, line_uid, '' 257 | return None 258 | 259 | 260 | def tag_in_tags_regex(tag, tags): 261 | return any(re.match(r'^' + t + r'$', tag) for t in map(str.strip, tags)) 262 | 263 | 264 | ps_pid = adb_device.shell("ps || ps -A") 265 | for line in ps_pid.splitlines(): 266 | pid_match = PID_LINE.match(line) 267 | if pid_match is not None: 268 | pid = pid_match.group(1) 269 | proc = pid_match.group(2) 270 | if proc in catchall_package: 271 | seen_pids = True 272 | pids.add(pid) 273 | 274 | 275 | for line in adb_stdout: 276 | if len(line) == 0: 277 | break 278 | 279 | bug_line = BUG_LINE.match(line) 280 | if bug_line is not None: 281 | continue 282 | 283 | log_line = LOG_LINE.match(line) 284 | if log_line is None: 285 | continue 286 | 287 | level, tag, owner, message = log_line.groups() 288 | tag = tag.strip() 289 | start = parse_start_proc(line) 290 | if start: 291 | line_package, target, line_pid, line_uid, line_gids = start 292 | if match_packages(line_package): 293 | pids.add(line_pid) 294 | 295 | app_pid = line_pid 296 | 297 | linebuf = '\n' 298 | linebuf += colorize(' ' * (header_size - 1), bg=WHITE) 299 | linebuf += indent_wrap(' Process %s created for %s\n' % 300 | (line_package, target)) 301 | linebuf += colorize(' ' * (header_size - 1), bg=WHITE) 302 | linebuf += ' PID: %s UID: %s GIDs: %s' % (line_pid, line_uid, 303 | line_gids) 304 | linebuf += '\n' 305 | print(linebuf) 306 | last_tag = None # Ensure next log gets a tag printed 307 | 308 | dead_pid, dead_pname = parse_death(tag, message) 309 | if dead_pid: 310 | pids.remove(dead_pid) 311 | linebuf = '\n' 312 | linebuf += colorize(' ' * (header_size - 1), bg=RED) 313 | linebuf += ' Process %s (PID: %s) ended' % (dead_pname, dead_pid) 314 | linebuf += '\n' 315 | print(linebuf) 316 | last_tag = None # Ensure next log gets a tag printed 317 | 318 | # Make sure the backtrace is printed after a native crash 319 | if tag == 'DEBUG': 320 | bt_line = BACKTRACE_LINE.match(message.lstrip()) 321 | if bt_line is not None: 322 | message = message.lstrip() 323 | owner = app_pid 324 | 325 | if not args.all and owner not in pids: 326 | continue 327 | if level in LOG_LEVELS_MAP and LOG_LEVELS_MAP[level] < min_level: 328 | continue 329 | if args.ignored_tag and tag_in_tags_regex(tag, args.ignored_tag): 330 | continue 331 | if args.tag and not tag_in_tags_regex(tag, args.tag): 332 | continue 333 | 334 | linebuf = '' 335 | 336 | if args.tag_width > 0: 337 | # right-align tag title and allocate color if needed 338 | if tag != last_tag or args.always_tags: 339 | last_tag = tag 340 | color = allocate_color(tag) 341 | tag = tag[-args.tag_width:].rjust(args.tag_width) 342 | linebuf += colorize(tag, fg=color) 343 | else: 344 | linebuf += ' ' * args.tag_width 345 | linebuf += ' ' 346 | 347 | # write out level colored edge 348 | if level in TAGTYPES: 349 | linebuf += TAGTYPES[level] 350 | else: 351 | linebuf += ' ' + level + ' ' 352 | linebuf += ' ' 353 | 354 | # format tag message using rules 355 | for matcher in RULES: 356 | replace = RULES[matcher] 357 | message = matcher.sub(replace, message) 358 | 359 | linebuf += indent_wrap(message) 360 | print(linebuf) 361 | -------------------------------------------------------------------------------- /adbutils/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openatx/adbutils/59f454318d25b91dd6582c8b7faa7a75c30f1b33/adbutils/py.typed -------------------------------------------------------------------------------- /adbutils/screenrecord.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Sun Apr 07 2024 19:29:55 by codeskyblue 5 | """ 6 | 7 | import abc 8 | import os 9 | import pathlib 10 | import shutil 11 | import signal 12 | import socket 13 | import subprocess 14 | import textwrap 15 | import threading 16 | import time 17 | from typing import Union 18 | import typing 19 | import weakref 20 | 21 | from retry import retry 22 | 23 | from adbutils.errors import AdbError 24 | from adbutils._utils import adb_path 25 | from adbutils._adb import AdbConnection, Network 26 | from adbutils._proto import ShellReturn 27 | from adbutils.sync import Sync 28 | 29 | 30 | class AbstractDevice(abc.ABC): 31 | @property 32 | @abc.abstractmethod 33 | def serial(self) -> str: 34 | pass 35 | 36 | @property 37 | @abc.abstractmethod 38 | def sync(self) -> Sync: 39 | pass 40 | 41 | @abc.abstractmethod 42 | def shell(self, cmd: str, stream: bool = False) -> Union[str, AdbConnection]: 43 | pass 44 | 45 | @abc.abstractmethod 46 | def shell2(self, cmd: str, stream=False) -> ShellReturn: 47 | pass 48 | 49 | @abc.abstractmethod 50 | def remove(self, path: str): 51 | pass 52 | 53 | 54 | class AbstractScreenrecord(abc.ABC): 55 | @abc.abstractmethod 56 | def is_recording(self) -> bool: 57 | """return whether recording""" 58 | 59 | @abc.abstractmethod 60 | def check_env(self) -> bool: 61 | """check if environment if valid""" 62 | 63 | @abc.abstractmethod 64 | def _start(self, filename: str): 65 | pass 66 | 67 | @abc.abstractmethod 68 | def _stop(self): 69 | pass 70 | 71 | def start_recording(self, filename: str): 72 | if self.is_recording(): 73 | print("recording already running") 74 | return 75 | self._start(filename) 76 | 77 | def stop_recording(self): 78 | if not self.is_recording(): 79 | print("recording alreay stopped") 80 | return 81 | self._stop() 82 | 83 | 84 | class ScreenrecordExtension(AbstractDevice): 85 | def __init__(self): 86 | self._record_client = None 87 | 88 | def start_recording(self, filename: str): 89 | """start video recording 90 | 91 | Raises: 92 | AdbError (when no record client) 93 | """ 94 | self.__get_screenrecord_impl().start_recording(filename) 95 | 96 | def stop_recording(self): 97 | """stop video recording""" 98 | return self.__get_screenrecord_impl().stop_recording() 99 | 100 | def is_recording(self) -> bool: 101 | """is recording""" 102 | return self.__get_screenrecord_impl().is_recording() 103 | 104 | def __get_screenrecord_impl(self) -> AbstractScreenrecord: 105 | if self._record_client: 106 | return self._record_client 107 | r1 = _ScrcpyScreenRecord(self) 108 | if r1.check_env(): 109 | self._record_client = r1 110 | return r1 111 | r2 = _AdbScreenRecord(self) 112 | if r2.check_env(): 113 | self._record_client = r2 114 | return r2 115 | raise AdbError("no valid screenrecord client") 116 | 117 | 118 | class _ScrcpyScreenRecord(AbstractScreenrecord): 119 | 120 | def __init__(self, d: AbstractDevice): 121 | self._d = d 122 | bin_name = "scrcpy" if os.name == "posix" else "scrcpy.exe" 123 | self._scrcpy_path = shutil.which(bin_name) 124 | self._p: subprocess.Popen = None 125 | 126 | def is_recording(self) -> bool: 127 | return bool(self._p and self._p.poll() is None) 128 | 129 | def check_env(self) -> bool: 130 | return self._scrcpy_path is not None 131 | 132 | def _start(self, filename: str): 133 | env = os.environ.copy() 134 | env["ADB"] = adb_path() 135 | env["ANDROID_SERIAL"] = self._d.serial 136 | self._p = subprocess.Popen( 137 | [self._scrcpy_path, "--no-control", "--no-display", "--record", filename], 138 | stdin=subprocess.DEVNULL, 139 | stdout=subprocess.DEVNULL, 140 | env=env, 141 | ) 142 | self._finalizer = weakref.finalize(self._p, self._p.kill) 143 | 144 | def _stop(self): 145 | self._finalizer.detach() 146 | self._p.send_signal(signal.SIGINT) 147 | try: 148 | returncode = self._p.wait(timeout=3) 149 | if returncode == 0: 150 | pass # 正常退出 151 | elif returncode == 1: 152 | raise AdbError("scrcpy error: start failure") 153 | elif returncode == 2: 154 | raise AdbError("scrcpy error: device disconnected while running") 155 | else: 156 | raise AdbError("scrcpy error", returncode) 157 | except subprocess.TimeoutExpired: 158 | self._p.kill() 159 | raise AdbError("scrcpy not handled SIGINT, killed") 160 | 161 | 162 | class _ScrcpyJarScreenrecord: 163 | """ 164 | # -y overwrite output files 165 | ffmpeg -i "output.h264" -c:v copy -f mp4 -y "video.mp4" 166 | 167 | 遗留问题:秒表视频,转化后的视频时长不对啊(本来5s的,转化后变成了20s) 168 | 169 | https://stackoverflow.com/questions/21263064/how-to-wrap-h264-into-a-mp4-container 170 | 171 | 协议没有完全理解,Frame的pts也没有。还是需要多看看scrcpy的代码才行。 172 | """ 173 | 174 | def __init__(self, d: AbstractDevice, h264_filename: str = None): 175 | self._d = d 176 | self._filename = h264_filename 177 | self._conn: AdbConnection = None 178 | self._done_event = threading.Event() 179 | 180 | def is_recording(self) -> bool: 181 | return self._conn and not self._conn.closed 182 | 183 | def _start(self, filename: str): 184 | self._filename = filename 185 | curdir = pathlib.Path(__file__).absolute().parent 186 | device_jar_path = "/data/local/tmp/scrcpy-server.jar" 187 | 188 | # scrcpy deleted 189 | scrcpy_server_jar_path = curdir.joinpath("binaries/scrcpy-server-1.24.jar") 190 | assert scrcpy_server_jar_path.exists() 191 | 192 | self._d.sync.push(scrcpy_server_jar_path, device_jar_path) 193 | 194 | opts = [ 195 | "control=false", 196 | "bit_rate=8000000", 197 | "tunnel_forward=true", 198 | "lock_video_orientation=-1", 199 | "send_dummy_byte=false", 200 | "send_device_meta=false", 201 | "send_frame_meta=true", 202 | "downsize_on_error=true", 203 | ] 204 | cmd = [ 205 | "CLASSPATH=" + device_jar_path, 206 | "app_process", 207 | "/", 208 | "--nice-name=scrcpy-server", 209 | "com.genymobile.scrcpy.Server", 210 | "1.24", 211 | ] + opts 212 | _c = self._d.shell(cmd, stream=True) 213 | c: AdbConnection = _c 214 | del _c 215 | message = c.conn.recv(100).decode("utf-8") 216 | print("Scrcpy:", message) 217 | self._conn = c 218 | threading.Thread( 219 | name="scrcpy_main", target=self._copy2null, args=(c.conn,), daemon=True 220 | ).start() 221 | time.sleep(0.1) 222 | stream_sock = self._safe_dial_scrcpy() 223 | fh = pathlib.Path(self._filename).open("wb") 224 | threading.Thread( 225 | name="socket_copy", 226 | target=self._copy2file, 227 | args=(stream_sock, fh), 228 | daemon=True, 229 | ).start() 230 | 231 | @retry(AdbError, tries=10, delay=0.1, jitter=0.01) 232 | def _safe_dial_scrcpy(self) -> socket.socket: 233 | return self._d.create_connection(Network.LOCAL_ABSTRACT, "scrcpy") 234 | 235 | def _copy2null(self, s: socket.socket): 236 | while True: 237 | try: 238 | chunk = s.recv(1024) 239 | if chunk == b"": 240 | print("O:", chunk.decode("utf-8")) 241 | break 242 | except: 243 | break 244 | print("Scrcpy mainThread stopped") 245 | 246 | def _copy2file(self, s: socket.socket, fh: typing.BinaryIO): 247 | while True: 248 | chunk = s.recv(1 << 16) 249 | if not chunk: 250 | break 251 | fh.write(chunk) 252 | fh.close() 253 | print("Copy h264 stream finished", flush=True) 254 | self._done_event.set() 255 | 256 | def _stop(self) -> bool: 257 | self._conn.close() 258 | self._done_event.wait(timeout=3.0) 259 | time.sleep(1) 260 | self._done_event.clear() 261 | 262 | 263 | class _AdbScreenRecord(AbstractScreenrecord): 264 | 265 | def __init__(self, d: AbstractDevice, remote_path=None, autostart=False): 266 | """The maxium record time is 3 minutes""" 267 | self._d = d 268 | if not remote_path: 269 | remote_path = "/sdcard/adbutils-tmp-video-%d.mp4" % int(time.time() * 1000) 270 | self._remote_path = remote_path 271 | self._stream = None 272 | 273 | def check_env(self) -> bool: 274 | ret = self._d.shell2(["which", "screenrecord"]) 275 | return ret.returncode == 0 276 | 277 | def is_recording(self) -> bool: 278 | return bool(self._stream and not self._stream.closed) 279 | 280 | def _start(self, filename: str): 281 | self._filename = filename 282 | script_content = textwrap.dedent( 283 | """\ 284 | #!/system/bin/sh 285 | # generate by adbutils 286 | screenrecord "$1" & 287 | PID=$! 288 | read ANY 289 | kill -INT $PID 290 | wait 291 | """ 292 | ).encode("utf-8") 293 | self._d.sync.push(script_content, "/sdcard/adbutils-screenrecord.sh") 294 | self._stream: AdbConnection = self._d.shell( 295 | ["sh", "/sdcard/adbutils-screenrecord.sh", self._remote_path], stream=True 296 | ) 297 | 298 | def _stop(self): 299 | self._stream.send(b"\n") 300 | self._stream.read_until_close() 301 | self._stream.close() 302 | 303 | self._d.sync.pull(self._remote_path, self._filename) 304 | self._d.remove(self._remote_path) 305 | -------------------------------------------------------------------------------- /adbutils/screenshot.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Sun Apr 07 2024 19:52:19 by codeskyblue 5 | """ 6 | 7 | import abc 8 | import io 9 | import logging 10 | import re 11 | from typing import Optional, Union 12 | from adbutils.errors import AdbError 13 | from adbutils.sync import Sync 14 | from adbutils._proto import WindowSize 15 | from PIL import Image 16 | 17 | try: 18 | from PIL import UnidentifiedImageError 19 | except ImportError: 20 | # fix for py37 21 | UnidentifiedImageError = OSError 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | class AbstractDevice(abc.ABC): 26 | @property 27 | @abc.abstractmethod 28 | def sync(self) -> Sync: 29 | pass 30 | 31 | @abc.abstractmethod 32 | def shell(self, cmd: str, encoding: Optional[str]) -> Union[str, bytes]: 33 | pass 34 | 35 | @abc.abstractmethod 36 | def window_size(self) -> WindowSize: 37 | pass 38 | 39 | @abc.abstractmethod 40 | def framebuffer(self) -> Image.Image: 41 | pass 42 | 43 | class ScreenshotExtesion(AbstractDevice): 44 | def screenshot(self, display_id: Optional[int] = None, error_ok: bool = True) -> Image.Image: 45 | """ Take a screenshot and return PIL.Image.Image object 46 | Args: 47 | display_id: int, default None, see "dumpsys SurfaceFlinger --display-id" for valid display IDs 48 | error_ok: bool, default True, if True, return a black image when capture failed 49 | 50 | Returns: 51 | PIL.Image.Image object 52 | 53 | Raises: 54 | AdbError: when capture failed and error_ok is False 55 | """ 56 | try: 57 | pil_image = self.__screencap(display_id) 58 | if pil_image.mode == "RGBA": 59 | pil_image = pil_image.convert("RGB") 60 | return pil_image 61 | except UnidentifiedImageError as e: 62 | logger.warning("screencap error: %s", e) 63 | if error_ok: 64 | wsize = self.window_size() 65 | return Image.new("RGB", wsize, (0, 0, 0)) 66 | else: 67 | raise AdbError("screencap error") from e 68 | 69 | def __screencap(self, display_id: int = None) -> Image.Image: 70 | """ Take a screenshot and return PIL.Image.Image object 71 | """ 72 | # framebuffer() is not stable, so here still use screencap 73 | cmdargs = ['screencap', '-p'] 74 | if display_id is not None: 75 | _id = self.__get_real_display_id(display_id) 76 | cmdargs.extend(['-d', _id]) 77 | png_bytes = self.shell(cmdargs, encoding=None) 78 | return Image.open(io.BytesIO(png_bytes)) 79 | 80 | def __get_real_display_id(self, display_id: int) -> str: 81 | # adb shell dumpsys SurfaceFlinger --display-id 82 | # Display 4619827259835644672 (HWC display 0): port=0 pnpId=GGL displayName="EMU_display_0" 83 | output = self.shell("dumpsys SurfaceFlinger --display-id") 84 | _RE = re.compile(r"Display (\d+) ") 85 | ids = _RE.findall(output) 86 | if not ids: 87 | raise AdbError("No display found, debug with 'dumpsys SurfaceFlinger --display-id'") 88 | if display_id >= len(ids): 89 | raise AdbError("Invalid display_id", display_id) 90 | return ids[display_id] -------------------------------------------------------------------------------- /adbutils/shell.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Sun Apr 07 2024 18:44:52 by codeskyblue 5 | """ 6 | 7 | import abc 8 | import datetime 9 | import json 10 | import logging 11 | import re 12 | import time 13 | from typing import List, Optional, Union 14 | from adbutils._proto import WindowSize, AppInfo, RunningAppInfo, BatteryInfo, BrightnessMode 15 | from adbutils.errors import AdbError, AdbInstallError 16 | from adbutils._utils import escape_special_characters 17 | from retry import retry 18 | 19 | from adbutils.sync import Sync 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | _DISPLAY_RE = re.compile( 24 | r".*DisplayViewport{.*?valid=true, .*?orientation=(?P\d+), .*?deviceWidth=(?P\d+), deviceHeight=(?P\d+).*" 25 | ) 26 | 27 | 28 | def is_percent(v): 29 | return isinstance(v, float) and v <= 1.0 30 | 31 | 32 | class AbstractShellDevice(abc.ABC): 33 | @abc.abstractmethod 34 | def shell(self, cmd: Union[str, List[str]]) -> str: 35 | pass 36 | 37 | @property 38 | @abc.abstractmethod 39 | def sync(self) -> Sync: 40 | pass 41 | 42 | 43 | class ShellExtension(AbstractShellDevice): 44 | def getprop(self, prop: str) -> str: 45 | return self.shell(["getprop", prop]).strip() 46 | 47 | def keyevent(self, key_code: Union[int, str]): 48 | """adb shell input keyevent KEY_CODE""" 49 | self.shell(["input", "keyevent", str(key_code)]) 50 | 51 | def volume_up(self, times: int = 1): 52 | """ 53 | Increase the volume by times step 54 | :param times: times to increase volume,default is 1(Wake up volume bar). 55 | :return: 56 | """ 57 | for _ in range(times): 58 | self.shell("input keyevent VOLUME_UP") 59 | time.sleep(0.5) 60 | 61 | def volume_down(self, times: int = 1): 62 | """ 63 | Decrease the volume by times step 64 | :param times: times to decrease volume,default is 1(Wake up volume bar). 65 | :return: 66 | """ 67 | for _ in range(times): 68 | self.shell("input keyevent VOLUME_DOWN") 69 | time.sleep(0.5) 70 | 71 | def volume_mute(self): 72 | self.shell("input keyevent VOLUME_MUTE") 73 | 74 | def reboot(self): 75 | self.shell("reboot") 76 | 77 | def switch_screen(self, enable: bool): 78 | """turn screen on/off""" 79 | return self.keyevent(224 if enable else 223) 80 | 81 | @property 82 | def brightness_value(self) -> int: 83 | """ 84 | Return screen brightness value, [0, 255] 85 | 86 | Examples: 87 | print(d.brightness_value) output:128 88 | """ 89 | value = self.shell('settings get system screen_brightness') 90 | return int(value.strip()) 91 | 92 | @brightness_value.setter 93 | def brightness_value(self, value: int): 94 | """ 95 | Set screen brightness values 96 | :param value: brightness value 97 | eg: d.brightness_value = 128 98 | """ 99 | if not isinstance(value, int): 100 | raise ValueError("Brightness value must be an integer") 101 | if not 0 <= value <= 255: 102 | raise ValueError("Brightness value must be between 0 and 255") 103 | self.shell(f"settings put system screen_brightness {value}") 104 | 105 | @property 106 | def brightness_mode(self) -> BrightnessMode: 107 | """ 108 | Return screen brightness mode 109 | :return: BrightnessMode.AUTO or BrightnessMode.MANUAL 110 | """ 111 | value = int(self.shell('settings get system screen_brightness_mode')) 112 | return BrightnessMode(value) 113 | 114 | @brightness_mode.setter 115 | def brightness_mode(self, mode: BrightnessMode): 116 | """ 117 | Set screen brightness mode 118 | 119 | Args: 120 | mode: BrightnessMode.AUTO or BrightnessMode.MANUAL 121 | 122 | Example: 123 | d.brightness_mode = BrightnessMode.AUTO 124 | """ 125 | if isinstance(mode, BrightnessMode): 126 | self.shell(f"settings put system screen_brightness_mode {mode.value}") 127 | else: 128 | raise ValueError("Brightness mode must be an instance of BrightnessMode") 129 | 130 | def switch_airplane(self, enable: bool): 131 | """turn airplane-mode on/off""" 132 | base_setting_cmd = ["settings", "put", "global", "airplane_mode_on"] 133 | base_am_cmd = [ 134 | "am", 135 | "broadcast", 136 | "-a", 137 | "android.intent.action.AIRPLANE_MODE", 138 | "--ez", 139 | "state", 140 | ] 141 | if enable: 142 | base_setting_cmd += ["1"] 143 | base_am_cmd += ["true"] 144 | else: 145 | base_setting_cmd += ["0"] 146 | base_am_cmd += ["false"] 147 | 148 | self.shell(base_setting_cmd) 149 | self.shell(base_am_cmd) 150 | 151 | def switch_wifi(self, enable: bool): 152 | """turn WiFi on/off""" 153 | arglast = "enable" if enable else "disable" 154 | cmdargs = ["svc", "wifi", arglast] 155 | self.shell(cmdargs) 156 | 157 | def window_size(self, landscape: Optional[bool] = None) -> WindowSize: 158 | """ 159 | Return screen (width, height) in pixel, width and height will be swapped if rotation is 90 or 270 160 | 161 | Args: 162 | landscape: bool, default None, if True, return (width, height), else return (height, width) 163 | 164 | Returns: 165 | WindowSize 166 | 167 | Raises: 168 | AdbError 169 | """ 170 | wsize = self._wm_size() 171 | if landscape is None: 172 | landscape = self.rotation() % 2 == 1 173 | logger.debug("get window size from 'wm size': %s %s", wsize, landscape) 174 | return WindowSize(wsize.height, wsize.width) if landscape else wsize 175 | 176 | def _wm_size(self) -> WindowSize: 177 | output = self.shell("wm size") 178 | o = re.search(r"Override size: (\d+)x(\d+)", output) 179 | if o: 180 | w, h = o.group(1), o.group(2) 181 | return WindowSize(int(w), int(h)) 182 | m = re.search(r"Physical size: (\d+)x(\d+)", output) 183 | if m: 184 | w, h = m.group(1), m.group(2) 185 | return WindowSize(int(w), int(h)) 186 | raise AdbError("wm size output unexpected", output) 187 | 188 | def swipe(self, sx, sy, ex, ey, duration: float = 1.0) -> None: 189 | """ 190 | swipe from start point to end point 191 | 192 | Args: 193 | sx, sy: start point(x, y) 194 | ex, ey: end point(x, y) 195 | """ 196 | if any(map(is_percent, [sx, sy, ex, ey])): 197 | w, h = self.window_size() 198 | sx = int(sx * w) if is_percent(sx) else sx 199 | sy = int(sy * h) if is_percent(sy) else sy 200 | ex = int(ex * w) if is_percent(ex) else ex 201 | ey = int(ey * h) if is_percent(ey) else ey 202 | x1, y1, x2, y2 = map(str, [sx, sy, ex, ey]) 203 | self.shell(["input", "swipe", x1, y1, x2, y2, str(int(duration * 1000))]) 204 | 205 | def click(self, x, y, display_id: Optional[int] = None) -> None: 206 | """ 207 | simulate android tap 208 | 209 | Args: 210 | x, y: int 211 | display_id: int, default None, see "dumpsys SurfaceFlinger --display-id" for valid display IDs 212 | """ 213 | if any(map(is_percent, [x, y])): 214 | w, h = self.window_size() 215 | x = int(x * w) if is_percent(x) else x 216 | y = int(y * h) if is_percent(y) else y 217 | x, y = map(str, [x, y]) 218 | cmdargs = ["input"] 219 | if display_id is not None: 220 | cmdargs.extend(['-d', str(display_id)]) 221 | self.shell(cmdargs + ["tap", x, y]) 222 | 223 | def send_keys(self, text: str): 224 | """ 225 | Type a given text 226 | 227 | Args: 228 | text: text to be type 229 | """ 230 | escaped_text = escape_special_characters(text) 231 | return self.shell(["input", "text", escaped_text]) 232 | 233 | def wlan_ip(self) -> str: 234 | """get device wlan ip address""" 235 | result = self.shell(["ifconfig", "wlan0"]) 236 | m = re.search(r"inet\s*addr:(.*?)\s", result, re.DOTALL) 237 | if m: 238 | return m.group(1) 239 | 240 | # Huawei P30, has no ifconfig 241 | result = self.shell(["ip", "addr", "show", "dev", "wlan0"]) 242 | m = re.search(r"inet (\d+.*?)/\d+", result) 243 | if m: 244 | return m.group(1) 245 | 246 | # On VirtualDevice, might use eth0 247 | result = self.shell(["ifconfig", "eth0"]) 248 | m = re.search(r"inet\s*addr:(.*?)\s", result, re.DOTALL) 249 | if m: 250 | return m.group(1) 251 | 252 | raise AdbError("fail to parse wlan ip") 253 | 254 | def rotation(self) -> int: 255 | """ 256 | Returns: 257 | int [0, 1, 2, 3] 258 | """ 259 | for line in self.shell("dumpsys display").splitlines(): 260 | m = re.search(r".*?orientation=(?P\d+)", line) 261 | if not m: 262 | continue 263 | o = int(m.group("orientation")) 264 | return int(o) 265 | raise AdbError("rotation get failed") 266 | 267 | def remove(self, path: str): 268 | """rm device file""" 269 | self.shell(["rm", path]) 270 | 271 | def rmtree(self, path: str): 272 | """rm -r directory""" 273 | self.shell(["rm", "-r", path]) 274 | 275 | def is_screen_on(self): 276 | output = self.shell(["dumpsys", "power"]) 277 | return "mHoldingDisplaySuspendBlocker=true" in output 278 | 279 | def open_browser(self, url: str): 280 | if not re.match("^https?://", url): 281 | url = "https://" + url 282 | self.shell(["am", "start", "-a", "android.intent.action.VIEW", "-d", url]) 283 | 284 | def list_packages(self, filter_list: Optional[List[str]] = None) -> List[str]: 285 | """ 286 | Args: 287 | filter_list (List[str]): package filter 288 | -f: See associated file. 289 | -d: Filter to only show disabled packages. 290 | -e: Filter to only show enabled packages. 291 | -s: Filter to only show system packages. 292 | -3: Filter to only show third-party packages. 293 | -i: See the installer for the packages. 294 | -u: Include uninstalled packages. 295 | --user user_id: The user space to query. 296 | Returns: 297 | list of package names 298 | """ 299 | result = [] 300 | cmd = ["pm", "list", "packages"] 301 | if filter_list: 302 | cmd.extend(filter_list) 303 | output = self.shell(cmd) 304 | for m in re.finditer(r"^package:([^\s]+)\r?$", output, re.M): 305 | result.append(m.group(1)) 306 | return list(sorted(result)) 307 | 308 | def uninstall(self, pkg_name: str): 309 | """ 310 | Uninstall app by package name 311 | 312 | Args: 313 | pkg_name (str): package name 314 | """ 315 | return self.shell(["pm", "uninstall", pkg_name]) 316 | 317 | def install_remote( 318 | self, remote_path: str, clean: bool = False, flags: list = ["-r", "-t"] 319 | ): 320 | """ 321 | Args: 322 | remote_path: remote package path 323 | clean(bool): remove when installed, default(False) 324 | flags (list): default ["-r", "-t"] 325 | 326 | Raises: 327 | AdbInstallError 328 | """ 329 | args = ["pm", "install"] + flags + [remote_path] 330 | output = self.shell(args) 331 | if "Success" not in output: 332 | raise AdbInstallError(output) 333 | if clean: 334 | self.shell(["rm", remote_path]) 335 | 336 | def app_start(self, package_name: str, activity: str = None): 337 | """start app with "am start" or "monkey" """ 338 | if activity: 339 | self.shell(["am", "start", "-n", package_name + "/" + activity]) 340 | else: 341 | self.shell( 342 | [ 343 | "monkey", 344 | "-p", 345 | package_name, 346 | "-c", 347 | "android.intent.category.LAUNCHER", 348 | "1", 349 | ] 350 | ) 351 | 352 | def app_stop(self, package_name: str): 353 | """stop app with "am force-stop" """ 354 | self.shell(["am", "force-stop", package_name]) 355 | 356 | def app_clear(self, package_name: str): 357 | self.shell(["pm", "clear", package_name]) 358 | 359 | def app_info(self, package_name: str) -> Optional[AppInfo]: 360 | """ 361 | Get app info 362 | 363 | Returns: 364 | None or AppInfo 365 | """ 366 | output = self.shell(["pm", "path", package_name]) 367 | if "package:" not in output: 368 | return None 369 | 370 | apk_paths = output.splitlines() 371 | apk_path = apk_paths[0].split(":", 1)[-1].strip() 372 | sub_apk_paths = list(map(lambda p: p.replace("package:", "", 1), apk_paths[1:])) 373 | 374 | output = self.shell(["dumpsys", "package", package_name]) 375 | m = re.compile(r"versionName=(?P[^\s]+)").search(output) 376 | version_name = m.group("name") if m else "" 377 | if version_name == "null": # Java dumps "null" for null values 378 | version_name = None 379 | m = re.compile(r"versionCode=(?P\d+)").search(output) 380 | version_code = m.group("code") if m else "" 381 | version_code = int(version_code) if version_code.isdigit() else None 382 | m = re.search(r"PackageSignatures\{.*?\[(.*)\]\}", output) 383 | signature = m.group(1) if m else None 384 | if not version_name and signature is None: 385 | return None 386 | m = re.compile(r"pkgFlags=\[\s*(.*)\s*\]").search(output) 387 | pkgflags = m.group(1) if m else "" 388 | pkgflags = pkgflags.split() 389 | 390 | time_regex = r"[-\d]+\s+[:\d]+" 391 | m = re.compile(f"firstInstallTime=({time_regex})").search(output) 392 | first_install_time = ( 393 | datetime.datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S") if m else None 394 | ) 395 | 396 | m = re.compile(f"lastUpdateTime=({time_regex})").search(output) 397 | last_update_time = ( 398 | datetime.datetime.strptime(m.group(1).strip(), "%Y-%m-%d %H:%M:%S") 399 | if m 400 | else None 401 | ) 402 | 403 | app_info = AppInfo( 404 | package_name=package_name, 405 | version_name=version_name, 406 | version_code=version_code, 407 | flags=pkgflags, 408 | first_install_time=first_install_time, 409 | last_update_time=last_update_time, 410 | signature=signature, 411 | path=apk_path, 412 | sub_apk_paths=sub_apk_paths, 413 | ) 414 | return app_info 415 | 416 | @retry(AdbError, delay=0.5, tries=3, jitter=0.1) 417 | def app_current(self) -> RunningAppInfo: 418 | """ 419 | Returns: 420 | RunningAppInfo(package, activity, pid?) pid can be 0 421 | 422 | Raises: 423 | AdbError 424 | """ 425 | # Related issue: https://github.com/openatx/uiautomator2/issues/200 426 | # $ adb shell dumpsys window windows 427 | # Example output: 428 | # mCurrentFocus=Window{41b37570 u0 com.incall.apps.launcher/com.incall.apps.launcher.Launcher} 429 | # mFocusedApp=AppWindowToken{422df168 token=Token{422def98 ActivityRecord{422dee38 u0 com.example/.UI.play.PlayActivity t14}}} 430 | # Regexp 431 | # r'mFocusedApp=.*ActivityRecord{\w+ \w+ (?P.*)/(?P.*) .*' 432 | # r'mCurrentFocus=Window{\w+ \w+ (?P.*)/(?P.*)\}') 433 | _focusedRE = re.compile( 434 | r"mCurrentFocus=Window{.*\s+(?P[^\s]+)/(?P[^\s]+)\}" 435 | ) 436 | m = _focusedRE.search(self.shell(["dumpsys", "window", "windows"])) 437 | if m: 438 | return RunningAppInfo( 439 | package=m.group("package"), activity=m.group("activity") 440 | ) 441 | 442 | # search mResumedActivity 443 | # https://stackoverflow.com/questions/13193592/adb-android-getting-the-name-of-the-current-activity 444 | package = None 445 | output = self.shell(["dumpsys", "activity", "activities"]) 446 | _recordRE = re.compile( 447 | r"mResumedActivity: ActivityRecord\{.*?\s+(?P[^\s]+)/(?P[^\s]+)\s.*?\}" 448 | ) # yapf: disable 449 | m = _recordRE.search(output) 450 | if m: 451 | package = m.group("package") 452 | 453 | # try: adb shell dumpsys activity top 454 | _activityRE = re.compile( 455 | r"ACTIVITY (?P[^\s]+)/(?P[^/\s]+) \w+ pid=(?P\d+)" 456 | ) 457 | output = self.shell(["dumpsys", "activity", "top"]) 458 | ms = _activityRE.finditer(output) 459 | ret = None 460 | for m in ms: 461 | ret = RunningAppInfo( 462 | package=m.group("package"), 463 | activity=m.group("activity"), 464 | pid=int(m.group("pid")), 465 | ) 466 | if ret.package == package: 467 | return ret 468 | 469 | if ret: # get last result 470 | return ret 471 | raise AdbError("Couldn't get focused app") 472 | 473 | def dump_hierarchy(self) -> str: 474 | """ 475 | uiautomator dump 476 | 477 | Returns: 478 | content of xml 479 | 480 | Raises: 481 | AdbError 482 | """ 483 | target = '/data/local/tmp/uidump.xml' 484 | output = self.shell( 485 | f'rm -f {target}; uiautomator dump {target} && echo success') 486 | if 'ERROR' in output or 'success' not in output: 487 | raise AdbError("uiautomator dump failed", output) 488 | 489 | buf = b'' 490 | for chunk in self.sync.iter_content(target): 491 | buf += chunk 492 | xml_data = buf.decode("utf-8") 493 | if not xml_data.startswith(' BatteryInfo: 498 | """ 499 | Get battery info 500 | 501 | Returns: 502 | BatteryInfo 503 | 504 | Details: 505 | AC powered - Indicates that the device is currently not powered by AC power. If true, it indicates that the device is connected to an AC power adapter. 506 | USB powered - Indicates that the device is currently being powered or charged through the USB interface. 507 | Wireless powered - Indicates that the device is not powered through wireless charging. If wireless charging is supported and currently in use, this will be true. 508 | Max charging current - The maximum charging current supported by the device, usually in microamperes( μ A). 509 | Max charging voltage - The maximum charging voltage supported by the device may be in millivolts (mV). 510 | Charge counter - The cumulative charge count of a battery, usually measured in milliampere hours (mAh) 511 | Status - Battery status code. 512 | Health - Battery health status code. 513 | Present - indicates that the battery is currently detected and installed in the device. 514 | Level - The percentage of current battery level. 515 | Scale - The full scale of the percentage of battery charge, indicating that the battery level is measured using 100 as the standard for full charge. 516 | Voltage - The current voltage of the battery, usually measured in millivolts (mV). 517 | Temperature - Battery temperature, usually measured in degrees Celsius (° C) 518 | Technology - Battery type, like (Li-ion) battery 519 | """ 520 | 521 | def to_bool(v: str) -> bool: 522 | return v == "true" 523 | 524 | output = self.shell(["dumpsys", "battery"]) 525 | shell_kvs = {} 526 | for line in output.splitlines(): 527 | key, val = line.strip().split(':', 1) 528 | shell_kvs[key.strip()] = val.strip() 529 | 530 | def get_key(k: str, map_function): 531 | v = shell_kvs.get(k) 532 | if v is not None: 533 | return map_function(v) 534 | return None 535 | 536 | ac_powered = get_key("AC powered", to_bool) 537 | usb_powered = get_key("USB powered", to_bool) 538 | wireless_powered = get_key("Wireless powered", to_bool) 539 | dock_powered = get_key("Dock powered", to_bool) 540 | max_charging_current = get_key("Max charging current", int) 541 | max_charging_voltage = get_key("Max charging voltage", int) 542 | charge_counter = get_key("Charge counter", int) 543 | status = get_key("status", int) 544 | health = get_key("health", int) 545 | present = get_key("present", to_bool) 546 | level = get_key("level", int) 547 | scale = get_key("scale", int) 548 | voltage = get_key("voltage", int) 549 | temperature = get_key("temperature", lambda x: int(x) / 10) 550 | technology = shell_kvs.get("technology", str) 551 | return BatteryInfo( 552 | ac_powered=ac_powered, 553 | usb_powered=usb_powered, 554 | wireless_powered=wireless_powered, 555 | dock_powered=dock_powered, 556 | max_charging_current=max_charging_current, 557 | max_charging_voltage=max_charging_voltage, 558 | charge_counter=charge_counter, 559 | status=status, 560 | health=health, 561 | present=present, 562 | level=level, 563 | scale=scale, 564 | voltage=voltage, 565 | temperature=temperature, 566 | technology=technology, 567 | ) 568 | -------------------------------------------------------------------------------- /adbutils/sync.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Sun Apr 07 2024 19:38:48 by codeskyblue 5 | """ 6 | 7 | 8 | import logging 9 | import struct 10 | import datetime 11 | import typing 12 | import os 13 | import io 14 | import stat 15 | import pathlib 16 | from contextlib import contextmanager 17 | 18 | from adbutils._adb import BaseClient, AdbError 19 | from adbutils._proto import FileInfo 20 | from adbutils._utils import append_path 21 | from adbutils.errors import AdbSyncError 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | _OKAY = "OKAY" 26 | _FAIL = "FAIL" 27 | _DENT = "DENT" # Directory Entity 28 | _DONE = "DONE" 29 | _DATA = "DATA" 30 | 31 | 32 | class Sync(): 33 | 34 | def __init__(self, adbclient: BaseClient, serial: str): 35 | self._adbclient = adbclient 36 | self._serial = serial 37 | 38 | @contextmanager 39 | def _prepare_sync(self, path: str, cmd: str): 40 | c = self._adbclient.make_connection() 41 | try: 42 | c.send_command(":".join(["host", "transport", self._serial])) 43 | c.check_okay() 44 | c.send_command("sync:") 45 | c.check_okay() 46 | # {COMMAND}{LittleEndianPathLength}{Path} 47 | path_len = len(path.encode('utf-8')) 48 | c.conn.send( 49 | cmd.encode("utf-8") + struct.pack(" bool: 56 | finfo = self.stat(path) 57 | return finfo.mtime is not None 58 | 59 | def stat(self, path: str) -> FileInfo: 60 | with self._prepare_sync(path, "STAT") as c: 61 | assert "STAT" == c.read_string(4) 62 | mode, size, mtime = struct.unpack(" typing.List[str]: 83 | return list(self.iter_directory(path)) 84 | 85 | def push(self, src: typing.Union[pathlib.Path, str, bytes, bytearray, typing.BinaryIO], 86 | dst: typing.Union[pathlib.Path, str], 87 | mode: int = 0o755, 88 | check: bool = False) -> int: 89 | """ 90 | Push file from local:src to device:dst 91 | 92 | Args: 93 | src: source file path 94 | dst: destination file path or directory path 95 | mode: file mode 96 | check: check if push size is correct 97 | 98 | Returns: 99 | total file size pushed 100 | """ 101 | if isinstance(dst, pathlib.Path): 102 | dst = dst.as_posix() 103 | finfo = self.stat(dst) 104 | if finfo.mode & stat.S_IFDIR != 0: 105 | if not isinstance(src, (pathlib.Path, str)): 106 | raise AdbSyncError("src should be a file path when dst is a directory") 107 | dst = append_path(dst, pathlib.Path(src).name) 108 | logger.debug("dst is a directory, update dst to %s", dst) 109 | return self._push_file(src, dst, mode, check) 110 | 111 | def _push_file( 112 | self, 113 | src: typing.Union[pathlib.Path, str, bytes, bytearray, typing.BinaryIO], 114 | dst: str, 115 | mode: int = 0o755, 116 | check: bool = False) -> int: # yapf: disable 117 | # IFREG: File Regular 118 | # IFDIR: File Directory 119 | if isinstance(src, pathlib.Path): 120 | src = src.open("rb") 121 | elif isinstance(src, str): 122 | src = pathlib.Path(src).open("rb") 123 | elif isinstance(src, (bytes, bytearray)): 124 | src = io.BytesIO(src) 125 | else: 126 | if not hasattr(src, "read"): 127 | raise TypeError("Invalid src type: %s" % type(src)) 128 | 129 | path = dst + "," + str(stat.S_IFREG | mode) 130 | total_size = 0 131 | with self._prepare_sync(path, "SEND") as c: 132 | r = src if hasattr(src, "read") else open(src, "rb") 133 | try: 134 | while True: 135 | chunk = r.read(4096) # should not >64k 136 | if not chunk: 137 | mtime = int(datetime.datetime.now().timestamp()) 138 | c.conn.send(b"DONE" + struct.pack(" typing.Iterator[bytes]: 158 | with self._prepare_sync(path, "RECV") as c: 159 | while True: 160 | cmd = c.read_string(4) 161 | if cmd == _FAIL: 162 | str_size = struct.unpack(" bytes: 177 | return b''.join(self.iter_content(path)) 178 | 179 | def read_text(self, path: str, encoding: str = 'utf-8') -> str: 180 | """ read content of a file """ 181 | return self.read_bytes(path).decode(encoding=encoding) 182 | 183 | def pull(self, src: str, dst: typing.Union[str, pathlib.Path], exist_ok: bool = False) -> int: 184 | """ 185 | Pull file or directory from device:src to local:dst 186 | 187 | Returns: 188 | total file size pulled 189 | """ 190 | src_file_info = self.stat(src) 191 | is_src_file = src_file_info.mode & stat.S_IFREG != 0 192 | 193 | if is_src_file: 194 | return self.pull_file(src, dst) 195 | else: 196 | return self.pull_dir(src, dst, exist_ok) 197 | 198 | 199 | def pull_file(self, src: str, dst: typing.Union[str, pathlib.Path]) -> int: 200 | """ 201 | Pull file from device:src to local:dst 202 | 203 | Returns: 204 | file size 205 | """ 206 | if isinstance(dst, str): 207 | dst = pathlib.Path(dst) 208 | with dst.open("wb") as f: 209 | size = 0 210 | for chunk in self.iter_content(src): 211 | f.write(chunk) 212 | size += len(chunk) 213 | return size 214 | 215 | def pull_dir(self, src: str, dst: typing.Union[str, pathlib.Path], exist_ok: bool = True) -> int: 216 | """Pull directory from device:src into local:dst 217 | 218 | Returns: 219 | total files size pulled 220 | """ 221 | 222 | def rec_pull_contents(src: str, dst: typing.Union[str, pathlib.Path], exist_ok: bool = True) -> int: 223 | s = 0 224 | items = list(self.iter_directory(src)) 225 | 226 | items = list(filter( 227 | lambda i: i.path != '.' and i.path != '..', 228 | items 229 | )) 230 | 231 | dirs = list( 232 | filter( 233 | lambda f: stat.S_IFDIR & f.mode != 0, 234 | items 235 | )) 236 | files = list( 237 | filter( 238 | lambda f: stat.S_IFREG & f.mode != 0, 239 | items 240 | )) 241 | 242 | for dir in dirs: 243 | new_src:str = append_path(src, dir.path) 244 | new_dst:pathlib.Path = pathlib.Path(append_path(dst, dir.path)) 245 | os.makedirs(new_dst, exist_ok=exist_ok) 246 | s += rec_pull_contents(new_src, new_dst ,exist_ok=exist_ok) 247 | 248 | for file in files: 249 | new_src:str = append_path(src, file.path) 250 | new_dst:str = append_path(dst, file.path) 251 | s += self.pull_file(new_src, new_dst) 252 | 253 | return s 254 | 255 | 256 | if isinstance(dst, str): 257 | dst = pathlib.Path(dst) 258 | os.makedirs(dst, exist_ok=exist_ok) 259 | 260 | return rec_pull_contents(src, dst, exist_ok=exist_ok) 261 | 262 | -------------------------------------------------------------------------------- /assets/images/pidcat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openatx/adbutils/59f454318d25b91dd6582c8b7faa7a75c30f1b33/assets/images/pidcat.png -------------------------------------------------------------------------------- /build_wheel.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding: utf-8 3 | import os 4 | import shutil 5 | import subprocess 6 | import sys 7 | import zipfile 8 | 9 | import requests 10 | 11 | ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) 12 | LIBNAME = "adbutils" 13 | BINARIES_DIR = os.path.join(ROOT_DIR, LIBNAME, "binaries") 14 | 15 | FNAMES_PER_PLATFORM = { 16 | "darwin": ["adb"], 17 | "linux": ["adb"], 18 | "win32": ["adb.exe", "AdbWinApi.dll", "AdbWinUsbApi.dll"], 19 | } 20 | 21 | BINARIES_URL = { 22 | "darwin": "https://dl.google.com/android/repository/platform-tools-latest-darwin.zip", 23 | "linux": "https://dl.google.com/android/repository/platform-tools-latest-linux.zip", 24 | "win32": "https://dl.google.com/android/repository/platform-tools-latest-windows.zip" 25 | } 26 | 27 | # https://peps.python.org/pep-0491/#file-format 28 | # https://packaging.python.org/en/latest/specifications/platform-compatibility-tags/ 29 | linux_plats = "manylinux1_x86_64" 30 | darwin_plats = "macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64" 31 | 32 | WHEEL_BUILDS = { 33 | "py3-none-" + darwin_plats: "darwin", 34 | "py3-none-" + linux_plats: "linux", # look into manylinux wheel builder 35 | "py3-none-win32": "win32", 36 | "py3-none-win_amd64": "win32", 37 | } 38 | 39 | 40 | def copy_binaries(target_dir, platform: str): 41 | assert os.path.isdir(target_dir) 42 | 43 | base_url = BINARIES_URL[platform] 44 | archive_name = os.path.join(target_dir, f'{platform}.zip') 45 | 46 | print("Downloading", base_url, "...", end=" ", flush=True) 47 | with open(archive_name, 'wb') as handle: 48 | response = requests.get(base_url, stream=True) 49 | if not response.ok: 50 | print(response) 51 | for block in response.iter_content(1024): 52 | if not block: 53 | break 54 | handle.write(block) 55 | print("done") 56 | 57 | for fname in FNAMES_PER_PLATFORM[platform]: 58 | print("Extracting", fname, "...", end=" ") 59 | # extract the specified file from the archive 60 | member_name = f'platform-tools/{fname}' 61 | extract_archive_file(archive_file=archive_name, file=member_name, destination_folder=target_dir) 62 | shutil.move(src=os.path.join(target_dir, member_name), dst=os.path.join(target_dir, fname)) 63 | 64 | # extracted files 65 | filename = os.path.join(target_dir, fname) 66 | if fname == "adb": 67 | os.chmod(filename, 0o755) 68 | print("done") 69 | 70 | os.rmdir(path=os.path.join(target_dir, 'platform-tools')) 71 | os.remove(path=archive_name) 72 | 73 | 74 | def extract_archive_file(archive_file, file, destination_folder): 75 | extension = archive_file.rsplit('.', 1)[-1].lower() 76 | 77 | if extension == 'zip': 78 | with zipfile.ZipFile(archive_file, 'r') as archive: 79 | archive.extract(member=file, path=destination_folder) 80 | 81 | 82 | def clear_binaries_dir(target_dir): 83 | assert os.path.isdir(target_dir) 84 | assert os.path.basename(target_dir) == "binaries" 85 | 86 | for fname in os.listdir(target_dir): 87 | if fname == "README.md" or fname.endswith(".py"): 88 | continue 89 | print("Removing", fname, "...", end=" ") 90 | os.remove(os.path.join(target_dir, fname)) 91 | print("done") 92 | 93 | 94 | def clean(): 95 | for root, dirs, files in os.walk(ROOT_DIR): 96 | for dname in dirs: 97 | if dname in ( 98 | "__pycache__", 99 | ".cache", 100 | "dist", 101 | "build", 102 | LIBNAME + ".egg-info", 103 | ): 104 | shutil.rmtree(os.path.join(root, dname)) 105 | print("Removing", dname) 106 | for fname in files: 107 | if fname.endswith((".pyc", ".pyo")): 108 | os.remove(os.path.join(root, fname)) 109 | print("Removing", fname) 110 | 111 | 112 | def build(): 113 | clean() 114 | # Clear binaries, we don't want them in the reference release 115 | clear_binaries_dir(BINARIES_DIR) 116 | 117 | print("Using setup.py to generate wheel ...", end="") 118 | subprocess.check_output( 119 | [sys.executable, "setup.py", "sdist", "bdist_wheel"], cwd=ROOT_DIR 120 | ) 121 | print("done") 122 | 123 | # Version is generated by pbr 124 | version = None 125 | distdir = os.path.join(ROOT_DIR, "dist") 126 | for fname in os.listdir(distdir): 127 | if fname.endswith(".whl"): 128 | version = fname.split("-")[1] 129 | break 130 | assert version 131 | 132 | # Prepare 133 | fname = "-".join([LIBNAME, version, "py3-none-any.whl"]) 134 | packdir = LIBNAME+"-"+version 135 | infodir = f"{LIBNAME}-{version}.dist-info" 136 | wheelfile = os.path.join(distdir, packdir, infodir, "WHEEL") 137 | print("Path:", os.path.join(distdir, fname)) 138 | assert os.path.isfile(os.path.join(distdir, fname)) 139 | 140 | print("Unpacking ...", end="") 141 | subprocess.check_output( 142 | [sys.executable, "-m", "wheel", "unpack", fname], cwd=distdir) 143 | os.remove(os.path.join(distdir, packdir, infodir, "RECORD")) 144 | print("done") 145 | 146 | # Build for different platforms 147 | for wheeltag, platform in WHEEL_BUILDS.items(): 148 | print(f"Edit for {platform} {wheeltag}") 149 | 150 | # copy binaries 151 | binary_dir = os.path.join(distdir, packdir, LIBNAME, "binaries") 152 | clear_binaries_dir(binary_dir) 153 | copy_binaries(binary_dir, platform) 154 | 155 | lines = [] 156 | for line in open(wheelfile, "r", encoding="UTF-8"): 157 | if line.startswith("Tag:"): 158 | line = "Tag: " + wheeltag 159 | lines.append(line.rstrip()) 160 | with open(wheelfile, "w", encoding="UTF-8") as f: 161 | f.write("\n".join(lines)) 162 | 163 | print("Pack ...", end="") 164 | subprocess.check_output( 165 | [sys.executable, "-m", "wheel", "pack", packdir], cwd=distdir) 166 | print("done") 167 | 168 | # Clean up 169 | os.remove(os.path.join(distdir, fname)) 170 | shutil.rmtree(os.path.join(distdir, packdir)) 171 | 172 | # Show overview 173 | print("Dist folder:") 174 | for fname in sorted(os.listdir(distdir)): 175 | s = os.stat(os.path.join(distdir, fname)).st_size 176 | print(" {:0.0f} KB {}".format(s / 2**10, fname)) 177 | 178 | 179 | def release(): 180 | """ Release the packages to pypi """ 181 | username = os.environ["PYPI_USERNAME"] 182 | password = os.environ['PYPI_PASSWORD'] 183 | subprocess.check_call( 184 | [sys.executable, "-m", "twine", "upload", "-u", username, '-p', password, "dist/*"]) 185 | 186 | 187 | if __name__ == "__main__": 188 | build() 189 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | token: 59cb8341-87db-45bd-b828-e8ae19cd4062 3 | -------------------------------------------------------------------------------- /docs/PROTOCOL.md: -------------------------------------------------------------------------------- 1 | # ADB Protocol 2 | Tested with adb 1.0.41, Capture data with Charles 3 | 4 | Charles settings 5 | 6 | Proxy->Port Forwarding 7 | 8 | ``` 9 | TCP Local(5555) -> Remote(localhost:5037) 10 | ``` 11 | 12 | execute adb command, e.g. `adb -P 5555 devices`, now data should be seen in charles. 13 | 14 | ADB Protocols from Android Source Code 15 | 16 | - [OVERVIEW.TXT](https://cs.android.com/android/platform/superproject/+/main:packages/modules/adb/OVERVIEW.TXT) 17 | - [SERVICES.TXT](https://cs.android.com/android/platform/superproject/+/main:packages/modules/adb/SERVICES.TXT) 18 | - [SYNC.TXT](https://cs.android.com/android/platform/superproject/+/main:packages/modules/adb/SYNC.TXT) 19 | 20 | ## host version 21 | ```bash 22 | >send 23 | 000chost:version 24 | 25 | send 32 | 0026host:tport:serial:GBG5T197110027690014 33 | 34 | send 44 | 0011host:list-forward 45 | 46 | send 51 | 000ehost:tport:any 52 | 0014host:killforward-all 53 | 54 | send 60 | 001fhost:tport:serial:emulator-5554 61 | 001ehost:forward:tcp:1234;tcp:4321 62 | 63 | ( may be "tcp:0" to pick any open port) 75 | localabstract: 76 | localreserved: 77 | localfilesystem: 78 | reverse --remove REMOTE remove specific reverse socket connection 79 | reverse --remove-all remove all reverse socket connections from device 80 | ``` 81 | 82 | ```bash 83 | $ adb reverse --list 84 | >send 85 | 0022host:tport:serial:GBG5T197110027690014 86 | 87 | send 91 | reverse:list-forward 92 | 93 | send 104 | 001fhost:tport:serial:emulator-5554002creverse:forward:localabstract:test;tcp:12345 105 | 106 | =2.0.6,<3.0 3 | retry2>=0.9,<1.0 4 | Pillow 5 | # Optional dependencies 6 | # apkutils>=2.0.0,<3.0 # Required for APK installation 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # https://docs.openstack.org/pbr/3.0.0/ 2 | # 3 | [metadata] 4 | name = adbutils 5 | author = codeskyblue 6 | author_email = codeskyblue@gmail.com 7 | summary = Pure Python Adb Library 8 | license = MIT 9 | # description-file = ABOUT.rst 10 | home_page = https://github.com/openatx/adbutils 11 | # all classifier can be found in https://pypi.python.org/pypi?%3Aaction=list_classifiers 12 | classifier = 13 | Development Status :: 4 - Beta 14 | Environment :: Console 15 | Intended Audience :: Developers 16 | Operating System :: POSIX 17 | Programming Language :: Python :: 3 18 | Programming Language :: Python :: 3.8 19 | Programming Language :: Python :: 3.9 20 | Programming Language :: Python :: 3.10 21 | Topic :: Software Development :: Libraries :: Python Modules 22 | Topic :: Software Development :: Testing 23 | 24 | [files] 25 | #package-data = 26 | # adbutils = binaries/* 27 | 28 | [bdist_wheel] 29 | universal = 0 30 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | # 4 | # Licensed under MIT 5 | # 6 | # https://docs.travis-ci.com/user/deployment/pypi/ 7 | 8 | from __future__ import print_function 9 | 10 | import setuptools 11 | import subprocess 12 | import sys 13 | 14 | print("setup.py arguments:", sys.argv) 15 | 16 | if sys.argv[-1] == "build_wheel": 17 | subprocess.call([sys.executable, "build_wheel.py"]) 18 | else: 19 | setuptools.setup( 20 | setup_requires=["pbr"], 21 | python_requires=">=3.8", 22 | pbr=True, 23 | package_data={"adbutils": ["py.typed"]}, 24 | extras_require={ 25 | "all": ["apkutils>=2.0.0,<3.0"], 26 | }, 27 | ) 28 | -------------------------------------------------------------------------------- /test_real_device/conftest.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import pytest 4 | from adbutils import AdbClient, adb, AdbDevice 5 | 6 | 7 | @pytest.fixture(scope="session") 8 | def device(): 9 | client = AdbClient() # port=5137 10 | return client.device() 11 | 12 | 13 | @pytest.fixture 14 | def device_tmp_path(device: AdbDevice): 15 | tmp_path = "/data/local/tmp/Hi-世界.txt" 16 | yield tmp_path 17 | device.remove(tmp_path) 18 | 19 | 20 | @pytest.fixture 21 | def device_tmp_dir(device: AdbDevice): 22 | tmp_path = "/data/local/tmp/adbutils-test" 23 | device.shell("mkdir -p {}".format(tmp_path)) 24 | yield tmp_path 25 | device.rmtree(tmp_path) 26 | 27 | 28 | @pytest.fixture 29 | def device_tmp_dir_path(device: AdbDevice): 30 | tmp_dir_path = "/sdcard/test_d" 31 | yield tmp_dir_path 32 | device.rmtree(tmp_dir_path) 33 | 34 | @pytest.fixture 35 | def local_src_in_dir(tmpdir): 36 | tmpdir.join('1.txt').write('1\n') 37 | tmpdir.join('2.txt').write('2\n') 38 | tmpdir.join('3.txt').write('3\n') 39 | 40 | a = tmpdir.mkdir('a') 41 | a.join('a1.txt').write('a1\n') 42 | 43 | aa = a.mkdir('aa') 44 | aa.join('aa1.txt').write('aa1\n') 45 | 46 | ab = a.mkdir('ab') 47 | ab.join('ab1.txt').write('ab1\n') 48 | ab.join('ab2.txt').write('ab2\n') 49 | 50 | b = tmpdir.mkdir('b') 51 | b.join('b1.txt').write('b1\n') 52 | 53 | c = tmpdir.mkdir('c') 54 | ca = c.mkdir('ca') 55 | ca.join('ca1.txt').write('ca1\n') 56 | 57 | caa = ca.mkdir('caa') 58 | caa.join('caa1.txt').write('caa1\n') 59 | 60 | cb = c.mkdir('cb') 61 | 62 | yield tmpdir 63 | -------------------------------------------------------------------------------- /test_real_device/test_adb.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | import adbutils 4 | import pytest 5 | from adbutils import AdbClient, DeviceEvent 6 | 7 | 8 | adb = adbutils.AdbClient("127.0.0.1", 5037) 9 | 10 | 11 | def test_server_version(): 12 | version = adb.server_version() 13 | assert isinstance(version, int) 14 | 15 | 16 | def test_adb_disconnect(): 17 | with pytest.raises(adbutils.AdbError): 18 | adb.disconnect("127.0.0.1:1234", raise_error=True) 19 | 20 | 21 | def test_wait_for(): 22 | adb.wait_for("127.0.0.1:1234", state="disconnect", timeout=1) 23 | adb.wait_for(transport="usb", state="device", timeout=1) 24 | 25 | with pytest.raises(adbutils.AdbTimeout): 26 | adb.wait_for(transport="usb", state="disconnect", timeout=.5) 27 | 28 | 29 | def test_track_device(): 30 | it = adb.track_devices() 31 | evt = next(it) 32 | assert isinstance(evt, DeviceEvent) -------------------------------------------------------------------------------- /test_real_device/test_deprecated.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Mon May 09 2022 17:31:25 by codeskyblue 5 | """ 6 | 7 | import pytest 8 | from adbutils import AdbDevice, adb 9 | 10 | 11 | def test_package_info(device: AdbDevice): 12 | pinfo = device.app_current() 13 | pinfo = device.package_info(pinfo.package) 14 | assert 'version_name' in pinfo 15 | 16 | 17 | @pytest.mark.skip("current_app is removed") 18 | def test_current_app(device: AdbDevice): 19 | info = device.current_app() 20 | assert 'package' in info 21 | assert 'activity' in info 22 | 23 | 24 | def test_client_shell(): 25 | ds = adb.device_list() 26 | serial = ds[0].serial 27 | assert adb.shell(serial, 'pwd').rstrip() == "/" -------------------------------------------------------------------------------- /test_real_device/test_device.py: -------------------------------------------------------------------------------- 1 | """ 2 | extra functions test 3 | 4 | 效果生效与否不好判定(例如屏幕亮暗),部分用例仅作冒烟测试 5 | """ 6 | 7 | import os 8 | import io 9 | import pathlib 10 | import re 11 | import time 12 | import filecmp 13 | import uuid 14 | 15 | import pytest 16 | 17 | import adbutils 18 | from adbutils import AdbDevice, Network, BrightnessMode 19 | from adbutils.errors import AdbSyncError 20 | 21 | 22 | def test_shell(device: AdbDevice): 23 | for text in ("foo", "?", "&", "but 123"): 24 | output = device.shell(['echo', '-n', text]) 25 | assert output == text 26 | 27 | output = device.shell("pwd", rstrip=False) 28 | assert output in ["/\n", "/\r\n"] 29 | 30 | 31 | def test_shell_without_encoding(device: AdbDevice): 32 | output = device.shell("echo -n hello", encoding=None) 33 | assert output == b"hello" 34 | 35 | ret = device.shell2("echo -n hello", encoding=None) 36 | assert ret.output == b"hello" 37 | 38 | 39 | def test_shell_stream(device: AdbDevice): 40 | c = device.shell(["echo", "-n", "hello world"], stream=True) 41 | output = c.read_until_close() 42 | assert output == "hello world" 43 | 44 | 45 | def test_adb_shell_raise_timeout(device: AdbDevice): 46 | with pytest.raises(adbutils.AdbTimeout): 47 | device.shell("sleep 10", timeout=1.0) 48 | 49 | 50 | def test_shell2(device: AdbDevice): 51 | cmd = "echo -n 'hello'; false" 52 | res = device.shell2(cmd) 53 | assert res.output == "hello" 54 | assert res.returncode == 1 55 | assert res.command == cmd 56 | 57 | 58 | def test_get_xxx(device: AdbDevice): 59 | assert device.get_serialno() 60 | assert device.get_state() == "device" 61 | # adb connect device devpath is "unknown" 62 | # assert device.get_devpath().startswith("usb:") 63 | 64 | 65 | def test_battery(device: AdbDevice): 66 | print(device.battery().level) 67 | 68 | 69 | def test_keyevent(device: AdbDevice): 70 | # make sure no error raised 71 | device.keyevent(4) 72 | device.volume_up(2) 73 | device.volume_down(3) 74 | device.volume_mute() 75 | 76 | 77 | def test_brightness(device: AdbDevice): 78 | current_brightness = device.brightness_value 79 | device.brightness_value = 100 80 | assert device.brightness_value == 100 81 | device.brightness_value = current_brightness 82 | 83 | current_mode = device.brightness_mode 84 | if current_mode == BrightnessMode.AUTO: 85 | device.brightness_mode = BrightnessMode.MANUAL 86 | assert device.brightness_mode == BrightnessMode.MANUAL 87 | elif current_mode == BrightnessMode.MANUAL: 88 | device.brightness_mode = BrightnessMode.AUTO 89 | assert device.brightness_mode == BrightnessMode.AUTO 90 | device.brightness_mode = current_mode 91 | 92 | 93 | def test_switch_screen(device: AdbDevice): 94 | device.switch_screen(False) 95 | device.switch_screen(True) 96 | 97 | 98 | def test_switch_airplane(device: AdbDevice): 99 | device.switch_airplane(True) 100 | device.switch_airplane(False) 101 | 102 | 103 | def test_switch_wifi(device: AdbDevice): 104 | device.switch_wifi(False) 105 | device.switch_wifi(True) 106 | 107 | 108 | def test_swipe(device: AdbDevice): 109 | device.swipe(100, 100, 400, 400) 110 | 111 | 112 | def test_click(device: AdbDevice): 113 | device.click(100, 100) 114 | 115 | 116 | def test_send_keys(device: AdbDevice): 117 | device.send_keys("1234") 118 | 119 | 120 | def test_wlan_ip(device: AdbDevice): 121 | device.switch_airplane(False) 122 | device.switch_wifi(True) 123 | time.sleep(3) 124 | ip = device.wlan_ip() 125 | assert ip, 'ip is empty' 126 | 127 | 128 | def test_app_start_stop(device: AdbDevice): 129 | d = device 130 | package_name = "io.appium.android.apis" 131 | if package_name not in d.list_packages(): 132 | pytest.skip(package_name + " should be installed, to start test") 133 | d.app_start(package_name) 134 | time.sleep(1) 135 | assert device.app_current().package == package_name 136 | d.app_stop(package_name) 137 | time.sleep(.5) 138 | assert device.app_current().package != package_name 139 | 140 | 141 | def test_sync_pull_push(device: AdbDevice, device_tmp_path, tmp_path: pathlib.Path): 142 | src = io.BytesIO(b"Hello 1") 143 | device.sync.push(src, device_tmp_path) 144 | assert b"Hello 1" == device.sync.read_bytes(device_tmp_path) 145 | 146 | device.sync.push(b"Hello 12", device_tmp_path) 147 | assert "Hello 12" == device.sync.read_text(device_tmp_path) 148 | 149 | target_path = tmp_path / "hi.txt" 150 | target_path.write_text("Hello Android") 151 | dst_path = tmp_path / "dst.txt" 152 | dst_path.unlink(missing_ok=True) 153 | 154 | device.sync.push(target_path, device_tmp_path) 155 | assert "Hello Android" == device.sync.read_text(device_tmp_path) 156 | device.sync.pull(device_tmp_path, dst_path) 157 | assert "Hello Android" == dst_path.read_text(encoding="utf-8") 158 | 159 | data = b"" 160 | for chunk in device.sync.iter_content(device_tmp_path): 161 | data += chunk 162 | assert b"Hello Android" == data 163 | 164 | 165 | def test_sync_pull_file_push(device: AdbDevice, device_tmp_path, tmp_path: pathlib.Path): 166 | src = io.BytesIO(b"Hello 1") 167 | device.sync.push(src, device_tmp_path) 168 | assert b"Hello 1" == device.sync.read_bytes(device_tmp_path) 169 | 170 | device.sync.push(b"Hello 12", device_tmp_path) 171 | assert "Hello 12" == device.sync.read_text(device_tmp_path) 172 | 173 | target_path = tmp_path / "hi.txt" 174 | target_path.write_text("Hello Android") 175 | dst_path = tmp_path / "dst.txt" 176 | dst_path.unlink(missing_ok=True) 177 | 178 | device.sync.push(target_path, device_tmp_path) 179 | assert "Hello Android" == device.sync.read_text(device_tmp_path) 180 | device.sync.pull_file(device_tmp_path, dst_path) 181 | assert "Hello Android" == dst_path.read_text(encoding="utf-8") 182 | 183 | data = b"" 184 | for chunk in device.sync.iter_content(device_tmp_path): 185 | data += chunk 186 | assert b"Hello Android" == data 187 | 188 | 189 | def test_sync_push_to_dir(device: AdbDevice, device_tmp_dir, tmp_path: pathlib.Path): 190 | random_data = str(uuid.uuid4()).encode() 191 | src = io.BytesIO(random_data) 192 | with pytest.raises(AdbSyncError): 193 | device.sync.push(src, device_tmp_dir) 194 | src_path = tmp_path.joinpath("random.txt") 195 | src_path.write_bytes(random_data) 196 | assert device.sync.push(src_path, device_tmp_dir) == len(random_data) 197 | assert random_data == device.sync.read_bytes(device_tmp_dir + "/random.txt") 198 | 199 | 200 | def test_screenshot(device: AdbDevice): 201 | im = device.screenshot() 202 | assert im.mode == "RGB" 203 | 204 | 205 | def test_framebuffer(device: AdbDevice): 206 | im = device.framebuffer() 207 | assert im.size 208 | 209 | 210 | def test_app_info(device: AdbDevice): 211 | pinfo = device.app_current() 212 | app_info = device.app_info(pinfo.package) 213 | assert app_info.package_name is not None 214 | 215 | 216 | def test_window_size(device: AdbDevice): 217 | w, h = device.window_size() 218 | assert isinstance(w, int) 219 | assert isinstance(h, int) 220 | 221 | is_landscape = device.rotation() % 2 == 1 222 | nw, nh = device.window_size(not is_landscape) 223 | assert w == nh and h == nw 224 | 225 | 226 | def test_is_screen_on(device: AdbDevice): 227 | bool_result = device.is_screen_on() 228 | assert isinstance(bool_result, bool) 229 | 230 | 231 | def test_open_browser(device: AdbDevice): 232 | device.open_browser("https://example.org") 233 | 234 | 235 | def test_dump_hierarchy(device: AdbDevice): 236 | output = device.dump_hierarchy() 237 | assert output.startswith("") 239 | 240 | 241 | def test_remove(device: AdbDevice): 242 | remove_path = "/data/local/tmp/touch.txt" 243 | device.shell(["touch", remove_path]) 244 | assert device.sync.exists(remove_path) 245 | device.remove(remove_path) 246 | assert not device.sync.exists(remove_path) 247 | 248 | 249 | # def test_create_connection(device: AdbDevice, device_tmp_path: str): 250 | # device.sync.push(b"hello", device_tmp_path) 251 | # device.create_connection(Network.LOCAL_FILESYSTEM, device_tmp_path) 252 | 253 | def test_logcat(device: AdbDevice, tmp_path: pathlib.Path): 254 | logcat_path = tmp_path / "logcat.txt" 255 | logcat = device.logcat(logcat_path, clear=True, command="logcat -v time", re_filter="I/TAG") 256 | device.shell(["log", "-p", "i", "-t", "TAG", "hello"]) 257 | time.sleep(.1) 258 | logcat.stop() 259 | assert logcat_path.exists() 260 | assert re.compile(r"I/TAG.*hello").search(logcat_path.read_text(encoding="utf-8")) 261 | 262 | 263 | # todo: make independent of already present stuff on the phone 264 | def test_pull_push_dirs( 265 | device: AdbDevice, 266 | device_tmp_dir_path: str, 267 | local_src_in_dir: pathlib.Path, 268 | tmp_path: pathlib.Path, 269 | ): 270 | def are_dir_trees_equal(dir1, dir2): 271 | """ 272 | Compare two directories recursively. Files in each directory are 273 | assumed to be equal if their names and contents are equal. 274 | 275 | NB: retreived from: https://stackoverflow.com/a/6681395 276 | 277 | @param dir1: First directory path 278 | @param dir2: Second directory path 279 | 280 | @return: True if the directory trees are the same and 281 | there were no errors while accessing the directories or files, 282 | False otherwise. 283 | """ 284 | 285 | dirs_cmp = filecmp.dircmp(dir1, dir2) 286 | if len(dirs_cmp.left_only) > 0 or len(dirs_cmp.right_only) > 0 or \ 287 | len(dirs_cmp.funny_files) > 0: 288 | return False 289 | (_, mismatch, errors) = filecmp.cmpfiles( 290 | dir1, dir2, dirs_cmp.common_files, shallow=False) 291 | if len(mismatch) > 0 or len(errors) > 0: 292 | return False 293 | for common_dir in dirs_cmp.common_dirs: 294 | new_dir1 = os.path.join(dir1, common_dir) 295 | new_dir2 = os.path.join(dir2, common_dir) 296 | if not are_dir_trees_equal(new_dir1, new_dir2): 297 | return False 298 | return True 299 | 300 | local_src_out_dir1 = tmp_path / 'dir1' 301 | local_src_out_dir2 = tmp_path / 'dir2' 302 | 303 | # TODO: push src support dir 304 | # device.push(local_src_in_dir, device_tmp_dir_path) 305 | device.adb_output("push", str(local_src_in_dir), device_tmp_dir_path) 306 | 307 | device.sync.pull_dir(device_tmp_dir_path, local_src_out_dir1) 308 | 309 | assert local_src_out_dir1.exists() 310 | assert local_src_out_dir1.is_dir() 311 | 312 | are_dir_trees_equal(local_src_in_dir, local_src_out_dir1) 313 | 314 | device.sync.pull(device_tmp_dir_path, local_src_out_dir2) 315 | 316 | assert local_src_out_dir2.exists() 317 | assert local_src_out_dir2.is_dir() 318 | 319 | are_dir_trees_equal(local_src_in_dir, local_src_out_dir2) 320 | -------------------------------------------------------------------------------- /test_real_device/test_forward_reverse.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | import pytest 4 | 5 | from adbutils import AdbDevice 6 | 7 | 8 | def test_forward(device: AdbDevice): 9 | """ 10 | Test commands: 11 | 12 | adb forward --list 13 | adb -s xxxxx forward --list 14 | """ 15 | device.forward("tcp:11221", "tcp:7912") 16 | exists = False 17 | for item in device.forward_list(): 18 | if item.local == "tcp:11221" and item.remote == "tcp:7912": 19 | assert item.serial == device.serial 20 | exists = True 21 | assert exists 22 | 23 | lport = device.forward_port("tcp:7912") 24 | assert isinstance(lport, int) 25 | 26 | 27 | def test_reverse(device: AdbDevice): 28 | """ 29 | Test commands: 30 | 31 | adb reverse --list 32 | adb -s xxxxx reverse --list 33 | """ 34 | device.reverse("tcp:12345", "tcp:4000") 35 | exists = False 36 | for item in device.reverse_list(): 37 | if item.remote == "tcp:12345" and item.local == "tcp:4000": 38 | exists = True 39 | assert exists 40 | -------------------------------------------------------------------------------- /test_real_device/test_import.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Fri May 06 2022 19:06:07 by codeskyblue 5 | """ 6 | 7 | import adbutils 8 | 9 | 10 | 11 | def test_import(): 12 | adbutils.WindowSize 13 | adbutils.adb_path 14 | adbutils.adb 15 | adbutils.AdbClient 16 | adbutils.AdbDevice 17 | adbutils.AdbError 18 | adbutils.AdbTimeout -------------------------------------------------------------------------------- /test_real_device/test_record.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Sun May 15 2022 22:12:45 by codeskyblue 5 | """ 6 | 7 | import os 8 | import time 9 | import adbutils 10 | 11 | 12 | def test_screenrecord(device: adbutils.AdbDevice): 13 | device.start_recording("output.mp4") 14 | time.sleep(2.0) 15 | device.stop_recording() 16 | assert os.path.exists("output.mp4") 17 | os.remove("output.mp4") -------------------------------------------------------------------------------- /test_real_device/test_utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Fri Dec 02 2022 17:00:03 by codeskyblue 5 | """ 6 | 7 | import os 8 | import pytest 9 | from adbutils import StopEvent 10 | from adbutils._utils import get_free_port, _get_bin_dir 11 | 12 | 13 | def test_stop_event(): 14 | stop_event = StopEvent() 15 | assert stop_event.is_stopped() == False 16 | 17 | stop_event.stop_nowait() 18 | assert stop_event.is_stopped() == True 19 | assert stop_event.is_done() == False 20 | 21 | with pytest.raises(TimeoutError): 22 | stop_event.stop(timeout=.1) 23 | assert stop_event.is_stopped() == True 24 | assert stop_event.is_done() == False 25 | 26 | stop_event.done() 27 | assert stop_event.is_done() == True 28 | 29 | 30 | def test_get_free_port(): 31 | port = get_free_port() 32 | assert port > 0 33 | 34 | 35 | def test_get_bin_dir(): 36 | _dir = _get_bin_dir() 37 | assert isinstance(_dir, str) 38 | assert "README.md" in os.listdir(_dir) -------------------------------------------------------------------------------- /tests/adb_server.py: -------------------------------------------------------------------------------- 1 | # 2 | # Created on Sun Mar 24 2024 codeskyblue 3 | # 4 | 5 | from __future__ import annotations 6 | 7 | import asyncio 8 | import functools 9 | import logging 10 | import re 11 | import struct 12 | from typing import Union, Callable, overload 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | @overload 18 | def encode(data: str) -> bytes: 19 | ... 20 | 21 | @overload 22 | def encode(data: bytes) -> bytes: 23 | ... 24 | 25 | @overload 26 | def encode(data: int) -> bytes: 27 | ... 28 | 29 | 30 | def encode(data): 31 | if isinstance(data, bytes): 32 | return encode_bytes(data) 33 | if isinstance(data, int): 34 | return encode_number(data) 35 | if isinstance(data, str): 36 | return encode_string(data) 37 | raise ValueError("data must be bytes or int") 38 | 39 | 40 | def encode_number(n: int) -> bytes: 41 | body = "{:04x}".format(n) 42 | return encode_bytes(body.encode()) 43 | 44 | def encode_string(s: str, encoding: str = 'utf-8') -> bytes: 45 | return encode_bytes(s.encode(encoding)) 46 | 47 | def encode_bytes(s: bytes) -> bytes: 48 | header = "{:04x}".format(len(s)).encode() 49 | return header + s 50 | 51 | 52 | 53 | COMMANDS: dict[Union[str, re.Pattern], Callable] = {} 54 | 55 | def register_command(name: Union[str, re.Pattern]): 56 | def wrapper(func): 57 | COMMANDS[name] = func 58 | return func 59 | return wrapper 60 | 61 | 62 | class Context: 63 | def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, server: "AdbServer" = None, command: str = None): 64 | self.reader = reader 65 | self.writer = writer 66 | self.server = server 67 | self.command = command 68 | 69 | async def send(self, data: bytes): 70 | self.writer.write(data) 71 | await self.writer.drain() 72 | 73 | async def recv(self, length: int) -> bytes: 74 | return await self.reader.read(length) 75 | 76 | async def recv_string_block(self) -> str: 77 | length = int((await self.recv(4)).decode(), 16) 78 | return (await self.recv(length)).decode() 79 | 80 | async def close(self): 81 | self.writer.close() 82 | await self.writer.wait_closed() 83 | 84 | async def send_shell_v2(self, stream_id: int, payload: bytes): 85 | header = bytes([stream_id]) + len(payload).to_bytes(4, "little") 86 | await self.send(header + payload) 87 | 88 | 89 | @register_command("host:version") 90 | async def host_version(ctx: Context): 91 | await ctx.send(b"OKAY") 92 | await ctx.send(encode_number(1234)) 93 | 94 | 95 | @register_command("host:kill") 96 | async def host_kill(ctx: Context): 97 | await ctx.send(b"OKAY") 98 | await ctx.close() 99 | await ctx.server.stop() 100 | # os.kill(os.getpid(), signal.SIGINT) 101 | 102 | @register_command("host:list-forward") 103 | async def host_list_forward(ctx: Context): 104 | await ctx.send(b"OKAY") 105 | await ctx.send(encode_string("123456 tcp:1234 tcp:4321")) 106 | 107 | @register_command(re.compile("host-serial:[0-9]*:features.*")) 108 | async def host_serial_features(ctx: Context): 109 | await ctx.send(b"OKAY") 110 | await ctx.send(encode_string("shell_v2,fake_feature")) 111 | 112 | 113 | def enable_devices(): 114 | @register_command("host:devices") 115 | async def host_devices(ctx: Context): 116 | await ctx.send(b"OKAY") 117 | await ctx.send(encode_string("dummydevice\tdevice\n")) 118 | 119 | @register_command("host:devices-l") 120 | async def host_devices_extended(ctx: Context): 121 | await ctx.send(b"OKAY") 122 | await ctx.send(encode_string("dummydevice\tdevice product:test_emu model:test_model device:test_device\n")) 123 | 124 | 125 | def invalidate_devices(): 126 | @register_command("host:devices") 127 | async def host_devices(ctx: Context): 128 | await ctx.send(b"OKAY") 129 | await ctx.send(encode_string("dummydevice")) 130 | 131 | @register_command("host:devices-l") 132 | async def host_devices_extended(ctx: Context): 133 | """""" 134 | await ctx.send(b"OKAY") 135 | await ctx.send(encode_string("dummydevice")) 136 | 137 | SHELL_DEBUGS = { 138 | "enable-devices": enable_devices, 139 | "invalidate-devices": invalidate_devices 140 | } 141 | 142 | SHELL_OUTPUTS = { 143 | "pwd": "/", 144 | "pwd; echo X4EXIT:$?": "/\nX4EXIT:0" 145 | } 146 | 147 | SHELL_V2_COMMANDS = { 148 | "v2-stdout-only": { 149 | "stdout": b"this is stdout\n", 150 | "stderr": b"", 151 | "exit": 0, 152 | }, 153 | "v2-stdout-stderr": { 154 | "stdout": b"this is stdout\n", 155 | "stderr": b"this is stderr\n", 156 | "exit": 1, 157 | }, 158 | } 159 | 160 | 161 | def send_shell_v2(writer, stream_id: int, payload: bytes): 162 | length = struct.pack(">I", len(payload)) 163 | return writer.send(bytes([stream_id]) + length + payload) 164 | 165 | @register_command(re.compile("host:tport:serial:.*")) 166 | async def host_tport_serial(ctx: Context): 167 | serial = ctx.command.split(":")[-1] 168 | if serial == "not-found": 169 | await ctx.send(b"FAIL") 170 | await ctx.send(encode("device not found")) 171 | return 172 | else: 173 | await ctx.send(b"OKAY") 174 | await ctx.send(b"\x00\x00\x00\x00\x00\x00\x00\x00") 175 | 176 | cmd = await ctx.recv_string_block() 177 | logger.info("recv shell cmd: %s", cmd) 178 | if cmd.startswith("shell,v2:"): 179 | await ctx.send(b"OKAY") 180 | shell_cmd = cmd.split(":", 1)[1] 181 | 182 | result = SHELL_V2_COMMANDS.get(shell_cmd) 183 | if result is None: 184 | await ctx.send_shell_v2(2, b"unknown shell_v2 command\n") 185 | await ctx.send_shell_v2(3, b"\x01") 186 | return 187 | 188 | if result["stdout"]: 189 | await ctx.send_shell_v2(1, result["stdout"]) 190 | if result["stderr"]: 191 | await ctx.send_shell_v2(2, result["stderr"]) 192 | await ctx.send_shell_v2(3, bytes([result["exit"]])) 193 | 194 | elif cmd.startswith("shell:"): 195 | await ctx.send(b"OKAY") 196 | shell_cmd = cmd.split(":", 1)[1] 197 | if shell_cmd in SHELL_OUTPUTS: 198 | await ctx.send((SHELL_OUTPUTS[shell_cmd].rstrip() + "\n").encode()) 199 | elif shell_cmd in SHELL_DEBUGS: 200 | SHELL_DEBUGS[shell_cmd]() 201 | await ctx.send(b"debug command executed") 202 | else: 203 | await ctx.send(b"unknown command") 204 | else: 205 | await ctx.send(b"FAIL") 206 | await ctx.send(encode("unsupported command")) 207 | 208 | 209 | async def handle_command(reader: asyncio.StreamReader, writer: asyncio.StreamWriter, server: "AdbServer"): 210 | try: 211 | # Receive the command from the client 212 | addr = writer.get_extra_info('peername') 213 | logger.info(f"Connection from %s", addr) 214 | cmd_length = int((await reader.readexactly(4)).decode(), 16) 215 | command = (await reader.read(cmd_length)).decode() 216 | logger.info("recv command: %s", command) 217 | command_handler: callable = None 218 | command_keys = list(COMMANDS.keys()) 219 | logger.debug("command_keys: %s", command_keys) 220 | for key in command_keys: 221 | if isinstance(key, str) and key == command: 222 | command_handler = COMMANDS[key] 223 | break 224 | elif isinstance(key, re.Pattern) and key.match(command): 225 | command_handler = COMMANDS[key] 226 | break 227 | 228 | logger.debug("command_handler: %s", command_handler) 229 | if command_handler is None: 230 | writer.write(b"FAIL") 231 | writer.write(encode(f"Unknown command: {command}")) 232 | await writer.drain() 233 | writer.close() 234 | return 235 | ctx = Context(reader, writer, server, command) 236 | await command_handler(ctx) 237 | await ctx.close() 238 | except asyncio.IncompleteReadError: 239 | pass 240 | 241 | 242 | 243 | class AdbServer: 244 | def __init__(self, port: int = 7305, host: str = '127.0.0.1'): 245 | self.port = port 246 | self.host = host 247 | self.server = None 248 | 249 | async def start(self): 250 | _handle = functools.partial(handle_command, server=self) 251 | self.server = await asyncio.start_server(_handle, self.host, self.port) 252 | addr = self.server.sockets[0].getsockname() 253 | print(f'ADB server listening on {addr}') 254 | 255 | async with self.server: 256 | try: 257 | # Keep running the server 258 | await self.server.serve_forever() 259 | except asyncio.CancelledError: 260 | pass 261 | 262 | async def stop(self): 263 | self.server.close() 264 | await self.server.wait_closed() 265 | 266 | 267 | 268 | async def adb_server(): 269 | host = '127.0.0.1' 270 | port = 7305 271 | await AdbServer(port, host).start() 272 | 273 | 274 | def run_adb_server(): 275 | try: 276 | import logzero 277 | logzero.setup_logger(__name__) 278 | except ImportError: 279 | pass 280 | 281 | try: 282 | asyncio.run(adb_server()) 283 | except: 284 | logger.exception("Error in adb_server") 285 | 286 | 287 | if __name__ == '__main__': 288 | run_adb_server() 289 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | 4 | import logging 5 | import threading 6 | from unittest import mock 7 | import adbutils 8 | import pytest 9 | import time 10 | import socket 11 | from adb_server import run_adb_server 12 | 13 | 14 | def check_port(port) -> bool: 15 | try: 16 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 17 | s.settimeout(0.1) 18 | s.connect(('127.0.0.1', port)) 19 | return True 20 | except (ConnectionRefusedError, OSError, socket.timeout): 21 | return False 22 | 23 | 24 | def wait_for_port(port, timeout:float=3, ready: bool = True): 25 | start_time = time.time() 26 | while True: 27 | if time.time() - start_time > timeout: 28 | raise TimeoutError(f"Port {port} is not being listened to within {timeout} seconds") 29 | if check_port(port) == ready: 30 | return 31 | time.sleep(0.1) 32 | 33 | 34 | @pytest.fixture(scope='function') 35 | def adb_server_fixture(): 36 | th = threading.Thread(target=run_adb_server, name='mock-adb-server') 37 | th.daemon = True 38 | th.start() 39 | wait_for_port(7305) 40 | yield 41 | adbutils.AdbClient(port=7305).server_kill() 42 | 43 | 44 | 45 | @pytest.fixture 46 | def adb(adb_server_fixture) -> adbutils.AdbClient: 47 | logging.basicConfig(level=logging.DEBUG) 48 | return adbutils.AdbClient(port=7305) -------------------------------------------------------------------------------- /tests/test_adb_server.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | 4 | 5 | import pytest 6 | import adbutils 7 | from adb_server import encode 8 | 9 | 10 | def test_encode(): 11 | assert encode(1234) == b'000404d2' 12 | 13 | 14 | def test_server_version(adb: adbutils.AdbClient): 15 | assert adb.server_version() == 1234 16 | 17 | 18 | def test_server_kill(adb: adbutils.AdbClient): 19 | adb.server_kill() 20 | 21 | 22 | def test_host_tport_serial(adb: adbutils.AdbClient): 23 | d = adb.device(serial="not-found") 24 | # pass 25 | with pytest.raises(adbutils.AdbError): 26 | d.open_transport() 27 | 28 | d = adb.device(serial="123456") 29 | d.open_transport() 30 | -------------------------------------------------------------------------------- /tests/test_adb_shell.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Mon May 06 2024 14:41:10 by codeskyblue 5 | """ 6 | 7 | from unittest import mock 8 | from adbutils._proto import ShellReturn 9 | import pytest 10 | import adbutils 11 | from adbutils.errors import AdbError 12 | 13 | 14 | def test_shell_pwd(adb: adbutils.AdbClient): 15 | d = adb.device(serial="123456") 16 | assert d.shell("pwd") == "/" 17 | 18 | def test_shell2_pwd(adb: adbutils.AdbClient): 19 | d = adb.device(serial="123456") 20 | assert d.shell2("pwd") == ShellReturn( 21 | command='pwd', 22 | returncode=0, 23 | output='/\n', 24 | ) 25 | 26 | def test_shellv2_stdout(adb: adbutils.AdbClient): 27 | d = adb.device(serial="123456") 28 | assert d.shell2("v2-stdout-only", v2=True) == ShellReturn( 29 | command='v2-stdout-only', 30 | returncode=0, 31 | output='this is stdout\n', 32 | stdout='this is stdout\n', 33 | ) 34 | 35 | def test_shellv2_stderr(adb: adbutils.AdbClient): 36 | d = adb.device(serial="123456") 37 | assert d.shell2("v2-stdout-stderr", rstrip=True, v2=True) == ShellReturn( 38 | command='v2-stdout-stderr', 39 | returncode=1, 40 | output='this is stdout\nthis is stderr', 41 | stdout='this is stdout', 42 | stderr='this is stderr' 43 | ) 44 | 45 | def test_shell_screenshot(adb: adbutils.AdbClient): 46 | d = adb.device(serial="123456") 47 | 48 | def mock_shell(cmd: str, encoding='utf-8', **kwargs): 49 | if encoding is None: 50 | return b"" 51 | if cmd == "wm size": 52 | return "Physical size: 1080x1920" 53 | return b"" 54 | 55 | d.shell = mock_shell 56 | d.rotation = lambda: 0 57 | 58 | with pytest.raises(AdbError): 59 | d.screenshot(error_ok=False) 60 | pil_img = d.screenshot(error_ok=True) 61 | assert pil_img.size == (1080, 1920) 62 | 63 | # assert pixel is blank 64 | pixel = pil_img.getpixel((0, 0)) 65 | assert pixel[:3] == (0, 0, 0) 66 | 67 | 68 | def test_window_size(adb: adbutils.AdbClient): 69 | d = adb.device(serial="123456") 70 | 71 | def mock_shell(cmd): 72 | if cmd == "wm size": 73 | return "Physical size: 1080x1920" 74 | if cmd == "dumpsys display": 75 | return "mViewports=[DisplayViewport{orientation=0]" 76 | return "" 77 | 78 | d.shell = mock_shell 79 | wsize = d.window_size() 80 | assert wsize.width == 1080 81 | assert wsize.height == 1920 82 | 83 | 84 | def test_shell_battery(adb: adbutils.AdbClient): 85 | d = adb.device(serial="123456") 86 | 87 | _DUMPSYS_BATTERY_ = """Current Battery Service state: 88 | AC powered: false 89 | USB powered: true 90 | Wireless powered: false 91 | Dock powered: false 92 | Max charging current: 0 93 | Max charging voltage: 0 94 | Charge counter: 10000 95 | status: 4 96 | health: 2 97 | present: true 98 | level: 80 99 | scale: 100 100 | voltage: 5000 101 | temperature: 250 102 | technology: Li-ion""" 103 | d.shell = lambda cmd: _DUMPSYS_BATTERY_ 104 | 105 | bat = d.battery() 106 | assert bat.ac_powered == False 107 | assert bat.wireless_powered == False 108 | assert bat.usb_powered == True 109 | assert bat.dock_powered == False 110 | assert bat.max_charging_current == 0 111 | assert bat.max_charging_voltage == 0 112 | assert bat.charge_counter == 10000 113 | assert bat.status == 4 114 | assert bat.health == 2 115 | assert bat.present == True 116 | assert bat.level == 80 117 | assert bat.scale == 100 118 | assert bat.voltage == 5000 119 | assert bat.temperature == 25.0 120 | assert bat.technology == "Li-ion" 121 | -------------------------------------------------------------------------------- /tests/test_devices.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | 4 | 5 | import pytest 6 | import adbutils 7 | from adbutils._proto import AdbDeviceInfo 8 | 9 | def test_host_devices(adb: adbutils.AdbClient): 10 | _dev = adb.device("any") 11 | assert _dev.shell(cmdargs="enable-devices") == 'debug command executed' 12 | devices = adb.list(extended=False) 13 | assert devices == [AdbDeviceInfo(serial="dummydevice", state="device", tags={})] 14 | 15 | 16 | def test_host_devices_invalid(adb: adbutils.AdbClient): 17 | _dev = adb.device("any") 18 | assert _dev.shell(cmdargs="invalidate-devices") == 'debug command executed' 19 | devices = adb.list(extended=False) 20 | assert devices == [] 21 | 22 | 23 | def test_host_devices_extended(adb: adbutils.AdbClient): 24 | _dev = adb.device("any") 25 | assert _dev.shell(cmdargs="enable-devices") == 'debug command executed' 26 | devices = adb.list(extended=True) 27 | assert devices == [AdbDeviceInfo(serial="dummydevice", state="device", tags={"product": "test_emu", "model": "test_model", "device": "test_device"})] 28 | 29 | 30 | def test_host_devices_extended_invalid(adb: adbutils.AdbClient): 31 | _dev = adb.device("any") 32 | assert _dev.shell(cmdargs="invalidate-devices") == 'debug command executed' 33 | devices = adb.list(extended=True) 34 | assert devices == [] -------------------------------------------------------------------------------- /tests/test_forward.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | """Created on Wed May 08 2024 21:45:15 by codeskyblue 5 | """ 6 | 7 | import adbutils 8 | 9 | 10 | def test_forward_list(adb: adbutils.AdbClient): 11 | items = adb.forward_list() 12 | assert len(items) == 1 13 | assert items[0].serial == "123456" 14 | assert items[0].local == "tcp:1234" 15 | assert items[0].remote == "tcp:4321" 16 | --------------------------------------------------------------------------------