1# Copyright 2023 The Chromium Authors 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5from __future__ import annotations 6 7import functools 8import logging 9import re 10import shlex 11import subprocess 12from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Tuple 13 14from crossbench import path as pth 15from crossbench.parse import PathParser 16from crossbench.plt.arch import MachineArch 17from crossbench.plt.posix import RemotePosixPlatform 18 19if TYPE_CHECKING: 20 from crossbench.plt.base import CmdArg, ListCmdArgs, Platform 21 from crossbench.types import JsonDict 22 23 24def _find_adb_bin(platform: Platform) -> pth.AnyPath: 25 adb_bin = platform.search_platform_binary( 26 name="adb", 27 macos=["adb", "~/Library/Android/sdk/platform-tools/adb"], 28 linux=["adb"], 29 win=["adb.exe", "Android/sdk/platform-tools/adb.exe"]) 30 if adb_bin: 31 return adb_bin 32 raise ValueError( 33 "Could not find adb binary." 34 "See https://developer.android.com/tools/adb fore more details.") 35 36 37def adb_devices( 38 platform: Platform, 39 adb_bin: Optional[pth.AnyPath] = None) -> Dict[str, Dict[str, str]]: 40 adb_bin = adb_bin or _find_adb_bin(platform) 41 output = platform.sh_stdout(adb_bin, "devices", "-l") 42 raw_lines = output.strip().splitlines()[1:] 43 result: Dict[str, Dict[str, str]] = {} 44 for line in raw_lines: 45 serial_id, details = line.split(" ", maxsplit=1) 46 result[serial_id.strip()] = _parse_adb_device_info(details.strip()) 47 return result 48 49 50def _parse_adb_device_info(value: str) -> Dict[str, str]: 51 parts = value.split(" ") 52 assert parts[0], "device" 53 return dict(part.split(":") for part in parts[1:]) 54 55 56class Adb: 57 58 _serial_id: str 59 _device_info: Dict[str, str] 60 _adb_bin: pth.AnyPath 61 62 def __init__(self, 63 host_platform: Platform, 64 device_identifier: Optional[str] = None, 65 adb_bin: Optional[pth.AnyPath] = None) -> None: 66 self._host_platform = host_platform 67 if adb_bin: 68 self._adb_bin = PathParser.binary_path(adb_bin, platform=host_platform) 69 else: 70 self._adb_bin = _find_adb_bin(host_platform) 71 self.start_server() 72 self._serial_id, self._device_info = self._find_serial_id(device_identifier) 73 logging.debug("ADB Selected device: %s %s", self._serial_id, 74 self._device_info) 75 assert self._serial_id 76 77 def _find_serial_id( 78 self, 79 device_identifier: Optional[str] = None) -> Tuple[str, Dict[str, str]]: 80 devices = self.devices() 81 if not devices: 82 raise ValueError("adb could not find any attached devices." 83 "Connect your device and use 'adb devices' to list all.") 84 if device_identifier is None: 85 if len(devices) != 1: 86 raise ValueError( 87 f"Too many adb devices attached, please specify one of: {devices}") 88 device_identifier = list(devices.keys())[0] 89 if not device_identifier: 90 raise ValueError(f"Invalid device identifier: {repr(device_identifier)}") 91 if device_identifier in devices: 92 return device_identifier, devices[device_identifier] 93 matches: List[str] = [] 94 under_name = device_identifier.replace(" ", "_") 95 for key, device_info in devices.items(): 96 for _, info_value in device_info.items(): 97 if device_identifier in info_value or (under_name in info_value): 98 matches.append(key) 99 if not matches: 100 raise ValueError( 101 f"Could not find adb device matching: '{device_identifier}'") 102 if len(matches) > 1: 103 raise ValueError( 104 f"Found {len(matches)} adb devices matching: '{device_identifier}'.\n" 105 f"Choices: {matches}") 106 return matches[0], devices[matches[0]] 107 108 def __str__(self) -> str: 109 info = f"info='{self._device_info}'" 110 if model := self._device_info.get("model"): 111 info = f"model={repr(model)}" 112 return f"adb(device_id={repr(self._serial_id)}, {info})" 113 114 def has_root(self) -> bool: 115 return self.shell_stdout("id").startswith("uid=0(root)") 116 117 def path(self, path: pth.AnyPathLike) -> pth.AnyPath: 118 return pth.AnyPosixPath(path) 119 120 @property 121 def serial_id(self) -> str: 122 return self._serial_id 123 124 @functools.cached_property 125 def build_version(self) -> int: 126 return int(self.getprop("ro.build.version.release")) 127 128 @property 129 def device_info(self) -> Dict[str, str]: 130 return self._device_info 131 132 def popen(self, 133 *args: CmdArg, 134 bufsize=-1, 135 shell: bool = False, 136 stdout=None, 137 stderr=None, 138 stdin=None, 139 env: Optional[Mapping[str, str]] = None, 140 quiet: bool = False) -> subprocess.Popen: 141 del shell 142 assert not env, "ADB does not support setting env vars." 143 if not quiet: 144 logging.debug("SHELL: %s", shlex.join(map(str, args))) 145 adb_cmd: ListCmdArgs = [self._adb_bin, "-s", self._serial_id, "shell"] 146 adb_cmd.extend(args) 147 return self._host_platform.popen( 148 *adb_cmd, bufsize=bufsize, stdout=stdout, stderr=stderr, stdin=stdin) 149 150 def _adb(self, 151 *args: CmdArg, 152 shell: bool = False, 153 capture_output: bool = False, 154 stdout=None, 155 stderr=None, 156 stdin=None, 157 env: Optional[Mapping[str, str]] = None, 158 quiet: bool = False, 159 check: bool = True, 160 use_serial_id: bool = True) -> subprocess.CompletedProcess: 161 del shell 162 adb_cmd: ListCmdArgs = [] 163 if use_serial_id: 164 adb_cmd = [self._adb_bin, "-s", self._serial_id] 165 else: 166 adb_cmd = [self._adb_bin] 167 adb_cmd.extend(args) 168 return self._host_platform.sh( 169 *adb_cmd, 170 capture_output=capture_output, 171 stdout=stdout, 172 stderr=stderr, 173 stdin=stdin, 174 env=env, 175 quiet=quiet, 176 check=check) 177 178 def _adb_stdout(self, 179 *args: CmdArg, 180 quiet: bool = False, 181 stdin=None, 182 encoding: str = "utf-8", 183 use_serial_id: bool = True, 184 check: bool = True) -> str: 185 result = self._adb_stdout_bytes( 186 *args, 187 quiet=quiet, 188 stdin=stdin, 189 use_serial_id=use_serial_id, 190 check=check) 191 return result.decode(encoding) 192 193 def _adb_stdout_bytes(self, 194 *args: CmdArg, 195 quiet: bool = False, 196 stdin=None, 197 use_serial_id: bool = True, 198 check: bool = True) -> bytes: 199 adb_cmd: ListCmdArgs = [] 200 if use_serial_id: 201 adb_cmd = [self._adb_bin, "-s", self._serial_id] 202 else: 203 adb_cmd = [self._adb_bin] 204 adb_cmd.extend(args) 205 return self._host_platform.sh_stdout_bytes( 206 *adb_cmd, quiet=quiet, check=check, stdin=stdin) 207 208 def shell_stdout(self, 209 *args: CmdArg, 210 quiet: bool = False, 211 encoding: str = "utf-8", 212 stdin=None, 213 env: Optional[Mapping[str, str]] = None, 214 check: bool = True) -> str: 215 result = self.shell_stdout_bytes( 216 *args, quiet=quiet, stdin=stdin, env=env, check=check) 217 return result.decode(encoding) 218 219 def shell_stdout_bytes(self, 220 *args: CmdArg, 221 quiet: bool = False, 222 stdin=None, 223 env: Optional[Mapping[str, str]] = None, 224 check: bool = True) -> bytes: 225 # -e: choose escape character, or "none"; default '~' 226 # -n: don't read from stdin 227 # -T: disable pty allocation 228 # -t: allocate a pty if on a tty (-tt: force pty allocation) 229 # -x: disable remote exit codes and stdout/stderr separation 230 if env: 231 raise ValueError("ADB shell only supports an empty env for now.") 232 # Need to escape spaces in args for adb shell 233 str_args = map(lambda x: str(x).replace(" ", "\\ "), args) 234 return self._adb_stdout_bytes( 235 "shell", *str_args, stdin=stdin, quiet=quiet, check=check) 236 237 def shell(self, 238 *args: CmdArg, 239 shell: bool = False, 240 capture_output: bool = False, 241 stdout=None, 242 stderr=None, 243 stdin=None, 244 env: Optional[Mapping[str, str]] = None, 245 quiet: bool = False, 246 check: bool = True) -> subprocess.CompletedProcess: 247 # See shell_stdout for more `adb shell` options. 248 adb_cmd: ListCmdArgs = ["shell", *args] 249 return self._adb( 250 *adb_cmd, 251 shell=shell, 252 capture_output=capture_output, 253 stdout=stdout, 254 stderr=stderr, 255 stdin=stdin, 256 env=env, 257 quiet=quiet, 258 check=check) 259 260 def start_server(self) -> None: 261 self._adb_stdout("start-server", use_serial_id=False) 262 263 def stop_server(self) -> None: 264 self.kill_server() 265 266 def kill_server(self) -> None: 267 self._adb_stdout("kill-server", use_serial_id=False) 268 269 def devices(self) -> Dict[str, Dict[str, str]]: 270 return adb_devices(self._host_platform, self._adb_bin) 271 272 def forward(self, local: int, remote: int, protocol: str = "tcp") -> int: 273 stdout = self._adb_stdout( 274 "forward", f"{protocol}:{local}", f"{protocol}:{remote}") 275 return int(stdout) 276 277 def forward_remove(self, local: int, protocol: str = "tcp") -> None: 278 self._adb("forward", "--remove", f"{protocol}:{local}") 279 280 def reverse(self, remote: int, local: int, protocol: str = "tcp") -> int: 281 stdout = self._adb_stdout( 282 "reverse", f"{protocol}:{remote}", f"{protocol}:{local}") 283 return int(stdout) 284 285 def reverse_remove(self, remote: int, protocol: str = "tcp") -> None: 286 self._adb("reverse", "--remove", f"{protocol}:{remote}") 287 288 def pull(self, device_src_path: pth.AnyPath, 289 local_dest_path: pth.LocalPath) -> None: 290 self._adb("pull", self.path(device_src_path), local_dest_path) 291 292 def push(self, local_src_path: pth.LocalPath, 293 device_dest_path: pth.AnyPath) -> None: 294 self._adb("push", local_src_path, self.path(device_dest_path)) 295 296 def cmd(self, 297 *args: str, 298 quiet: bool = False, 299 encoding: str = "utf-8") -> str: 300 cmd: ListCmdArgs = ["cmd", *args] 301 return self.shell_stdout(*cmd, quiet=quiet, encoding=encoding) 302 303 def dumpsys(self, 304 *args: str, 305 quiet: bool = False, 306 encoding: str = "utf-8") -> str: 307 cmd: ListCmdArgs = ["dumpsys", *args] 308 return self.shell_stdout(*cmd, quiet=quiet, encoding=encoding) 309 310 def getprop(self, 311 *args: str, 312 quiet: bool = False, 313 encoding: str = "utf-8") -> str: 314 cmd: ListCmdArgs = ["getprop", *args] 315 return self.shell_stdout(*cmd, quiet=quiet, encoding=encoding).strip() 316 317 def services(self, quiet: bool = False, encoding: str = "utf-8") -> List[str]: 318 lines = list( 319 self.cmd("-l", quiet=quiet, encoding=encoding).strip().splitlines()) 320 lines = lines[1:] 321 lines.sort() 322 return [line.strip() for line in lines] 323 324 def packages(self, quiet: bool = False, encoding: str = "utf-8") -> List[str]: 325 # adb shell cmd package list packages 326 raw_list = self.cmd( 327 "package", "list", "packages", quiet=quiet, 328 encoding=encoding).strip().splitlines() 329 packages = [package.split(":", maxsplit=2)[1] for package in raw_list] 330 packages.sort() 331 return packages 332 333 def force_stop(self, package_name: str) -> None: 334 if not package_name: 335 raise ValueError("Got empty package name") 336 self.shell("am", "force-stop", package_name) 337 338 def force_clear(self, package_name: str) -> None: 339 if not package_name: 340 raise ValueError("Got empty package name") 341 cmd: ListCmdArgs = ["pm", "clear"] 342 if self.build_version >= 14: 343 user = self.cmd("user", "get-main-user").strip() 344 cmd.extend(["--user", user]) 345 cmd.extend([package_name]) 346 self.shell(*cmd) 347 348 def install(self, 349 bundle: pth.LocalPath, 350 allow_downgrade: bool = False, 351 modules: Optional[str] = None) -> None: 352 if bundle.suffix == ".apks": 353 self.install_apks(bundle, allow_downgrade, modules) 354 if bundle.suffix == ".apk": 355 self.install_apk(bundle, allow_downgrade) 356 357 def install_apk(self, 358 apk: pth.LocalPath, 359 allow_downgrade: bool = False) -> None: 360 if not apk.exists(): 361 raise ValueError(f"APK {apk} does not exist.") 362 args = ["install"] 363 if allow_downgrade: 364 args.append("-d") 365 args.append(str(apk)) 366 self._adb(*args) 367 368 def install_apks(self, 369 apks: pth.LocalPath, 370 allow_downgrade: bool = False, 371 modules: Optional[str] = None) -> None: 372 if not apks.exists(): 373 raise ValueError(f"APK {apks} does not exist.") 374 cmd = [ 375 "bundletool", 376 "install-apks", 377 f"--apks={apks}", 378 f"--device-id={self._serial_id}", 379 ] 380 if allow_downgrade: 381 cmd.append("--allow-downgrade") 382 if modules: 383 cmd.append(f"--modules={modules}") 384 self._host_platform.sh(*cmd) 385 386 def uninstall(self, package_name: str, missing_ok: bool = False) -> None: 387 if not package_name: 388 raise ValueError("Got empty package name") 389 try: 390 self._adb("uninstall", package_name) 391 except Exception as e: # pylint: disable=broad-except 392 if missing_ok: 393 logging.debug("Could not uninstall %s: %s", package_name, e) 394 else: 395 raise 396 397 def grant_notification_permissions(self, package_name: str) -> None: 398 if self.build_version < 13: 399 # Notification permission setting is needed for Android 13 and above. 400 # https://developer.android.com/develop/ui/views/notifications/notification-permission # pylint: disable=line-too-long 401 return 402 if not package_name: 403 raise ValueError("Got empty package name") 404 cmd: ListCmdArgs = ["pm", "grant"] 405 if self.build_version >= 14: 406 user = self.cmd("user", "get-main-user").strip() 407 cmd.extend(["--user", user]) 408 cmd.extend([package_name, "android.permission.POST_NOTIFICATIONS"]) 409 self.shell(*cmd) 410 411 412class AndroidAdbPlatform(RemotePosixPlatform): 413 414 def __init__(self, 415 host_platform: Platform, 416 device_identifier: Optional[str] = None, 417 adb: Optional[Adb] = None) -> None: 418 super().__init__(host_platform) 419 self._system_details: Optional[Dict[str, Any]] = None 420 self._cpu_details: Optional[Dict[str, Any]] = None 421 assert not host_platform.is_remote, ( 422 "adb on remote platform is not supported yet") 423 self._adb = adb or Adb(host_platform, device_identifier) 424 425 @property 426 def is_android(self) -> bool: 427 return True 428 429 @property 430 def name(self) -> str: 431 return "android" 432 433 @functools.cached_property 434 def version(self) -> str: #pylint: disable=invalid-overridden-method 435 return str(self.adb.build_version) 436 437 @functools.cached_property 438 def device(self) -> str: #pylint: disable=invalid-overridden-method 439 return self.adb.getprop("ro.product.model") 440 441 @property 442 def serial_id(self): 443 return self._adb.serial_id 444 445 @functools.cached_property 446 def cpu(self) -> str: #pylint: disable=invalid-overridden-method 447 variant = self.adb.getprop("dalvik.vm.isa.arm.variant") 448 platform = self.adb.getprop("ro.board.platform") 449 cpu_str = f"{variant} {platform}" 450 if cores_info := self._get_cpu_cores_info(): 451 cpu_str = f"{cpu_str} {cores_info}" 452 return cpu_str 453 454 @property 455 def adb(self) -> Adb: 456 return self._adb 457 458 _MACHINE_ARCH_LOOKUP = { 459 "arm64-v8a": MachineArch.ARM_64, 460 "armeabi-v7a": MachineArch.ARM_32, 461 "x86": MachineArch.IA32, 462 "x86_64": MachineArch.X64, 463 } 464 465 @functools.cached_property 466 def machine(self) -> MachineArch: #pylint: disable=invalid-overridden-method 467 cpu_abi = self.adb.getprop("ro.product.cpu.abi") 468 arch = self._MACHINE_ARCH_LOOKUP.get(cpu_abi, None) 469 if not arch: 470 raise ValueError(f"Unknown android CPU ABI: {cpu_abi}") 471 return arch 472 473 def app_path_to_package(self, app_path: pth.AnyPathLike) -> str: 474 path = self.path(app_path) 475 if len(path.parts) > 1: 476 raise ValueError(f"Invalid android package name: '{path}'") 477 package: str = path.parts[0] 478 packages = self.adb.packages() 479 if package not in packages: 480 raise ValueError(f"Package '{package}' is not installed on {self._adb}") 481 return package 482 483 def search_binary(self, app_or_bin: pth.AnyPathLike) -> Optional[pth.AnyPath]: 484 app_or_bin_path = self.path(app_or_bin) 485 if not app_or_bin_path.parts: 486 raise ValueError("Got empty path") 487 if result_path := self.which(app_or_bin_path): 488 return result_path 489 if str(app_or_bin) in self.adb.packages(): 490 return app_or_bin_path 491 return None 492 493 def home(self) -> pth.AnyPath: 494 raise RuntimeError("Cannot access home dir on (non-rooted) android device") 495 496 _VERSION_NAME_RE = re.compile(r"versionName=(?P<version>.+)") 497 498 def app_version(self, app_or_bin: pth.AnyPathLike) -> str: 499 # adb shell dumpsys package com.chrome.canary | grep versionName -C2 500 package = self.app_path_to_package(app_or_bin) 501 package_info = self.adb.dumpsys("package", str(package)) 502 match_result = self._VERSION_NAME_RE.search(package_info) 503 if match_result is None: 504 raise ValueError( 505 f"Could not find version for '{package}': {package_info}") 506 return match_result.group("version") 507 508 def process_children(self, 509 parent_pid: int, 510 recursive: bool = False) -> List[Dict[str, Any]]: 511 # TODO: implement 512 return [] 513 514 def foreground_process(self) -> Optional[Dict[str, Any]]: 515 # adb shell dumpsys activity activities 516 # TODO: implement 517 return None 518 519 def get_relative_cpu_speed(self) -> float: 520 # TODO figure out 521 return 1.0 522 523 def python_details(self) -> JsonDict: 524 # Python is not available on android. 525 return {} 526 527 def os_details(self) -> JsonDict: 528 # TODO: add more info 529 return {"version": self.version} 530 531 def check_autobrightness(self) -> bool: 532 # adb shell dumpsys display 533 # TODO: implement. 534 return True 535 536 _BRIGHTNESS_RE = re.compile( 537 r"mLatestFloatBrightness=(?P<brightness>[0-9]+\.[0-9]+)") 538 539 def get_main_display_brightness(self) -> int: 540 display_info: str = self.adb.shell_stdout("dumpsys", "display") 541 match_result = self._BRIGHTNESS_RE.search(display_info) 542 if match_result is None: 543 raise ValueError("Could not parse adb display brightness.") 544 return int(float(match_result.group("brightness")) * 100) 545 546 @property 547 def default_tmp_dir(self) -> pth.AnyPath: 548 return self.path("/data/local/tmp/") 549 550 def sh(self, 551 *args: CmdArg, 552 shell: bool = False, 553 capture_output: bool = False, 554 stdout=None, 555 stderr=None, 556 stdin=None, 557 env: Optional[Mapping[str, str]] = None, 558 quiet: bool = False, 559 check: bool = False) -> subprocess.CompletedProcess: 560 return self.adb.shell( 561 *args, 562 shell=shell, 563 capture_output=capture_output, 564 stdout=stdout, 565 stderr=stderr, 566 stdin=stdin, 567 env=env, 568 quiet=quiet, 569 check=check) 570 571 def sh_stdout_bytes(self, 572 *args: CmdArg, 573 shell: bool = False, 574 quiet: bool = False, 575 stdin=None, 576 env: Optional[Mapping[str, str]] = None, 577 check: bool = True) -> bytes: 578 # The shell option is not supported on adb. 579 del shell 580 return self.adb.shell_stdout_bytes( 581 *args, stdin=stdin, env=env, quiet=quiet, check=check) 582 583 def popen(self, 584 *args: CmdArg, 585 bufsize=-1, 586 shell: bool = False, 587 stdout=None, 588 stderr=None, 589 stdin=None, 590 env: Optional[Mapping[str, str]] = None, 591 quiet: bool = False) -> subprocess.Popen: 592 return self.adb.popen( 593 *args, 594 bufsize=bufsize, 595 shell=shell, 596 stdout=stdout, 597 stderr=stderr, 598 stdin=stdin, 599 env=env, 600 quiet=quiet) 601 602 def port_forward(self, local_port: int, remote_port: int) -> int: 603 return self.adb.forward(local_port, remote_port, protocol="tcp") 604 605 def stop_port_forward(self, local_port: int) -> None: 606 self.adb.forward_remove(local_port, protocol="tcp") 607 608 def reverse_port_forward(self, remote_port: int, local_port: int) -> int: 609 return self.adb.reverse(remote_port, local_port, protocol="tcp") 610 611 def stop_reverse_port_forward(self, remote_port: int) -> None: 612 self.adb.reverse_remove(remote_port, protocol="tcp") 613 614 def pull(self, from_path: pth.AnyPath, 615 to_path: pth.LocalPath) -> pth.LocalPath: 616 device_path = self.path(from_path) 617 if not self.exists(device_path): 618 raise ValueError(f"Source file '{from_path}' does not exist on {self}") 619 local_host_path = self.host_path(to_path) 620 local_host_path.parent.mkdir(parents=True, exist_ok=True) 621 self.adb.pull(device_path, local_host_path) 622 return to_path 623 624 def push(self, from_path: pth.LocalPath, to_path: pth.AnyPath) -> pth.AnyPath: 625 to_path = self.path(to_path) 626 self.adb.push(self.host_path(from_path), to_path) 627 return to_path 628 629 def processes(self, 630 attrs: Optional[List[str]] = None) -> List[Dict[str, Any]]: 631 lines = self.sh_stdout("ps", "-A", "-o", "PID,NAME").splitlines() 632 if len(lines) == 1: 633 return [] 634 635 res: List[Dict[str, Any]] = [] 636 for line in lines[1:]: 637 tokens = line.strip().split(maxsplit=1) 638 assert len(tokens) == 2, f"Got invalid process tokens: {tokens}" 639 res.append({"pid": int(tokens[0]), "name": tokens[1]}) 640 return res 641 642 def cpu_details(self) -> Dict[str, Any]: 643 if self._cpu_details: 644 return self._cpu_details 645 # TODO: Implement properly (i.e. remove all n/a values) 646 self._cpu_details = { 647 "info": self.cpu, 648 "physical cores": "n/a", 649 "logical cores": "n/a", 650 "usage": "n/a", 651 "total usage": "n/a", 652 "system load": "n/a", 653 "max frequency": "n/a", 654 "min frequency": "n/a", 655 "current frequency": "n/a", 656 } 657 return self._cpu_details 658 659 _GETPROP_RE = re.compile(r"^\[(?P<key>[^\]]+)\]: \[(?P<value>[^\]]+)\]$") 660 661 def _getprop_system_details(self) -> Dict[str, Any]: 662 details = super().system_details() 663 properties: Dict[str, str] = {} 664 for line in self.adb.shell_stdout("getprop").strip().splitlines(): 665 result = self._GETPROP_RE.fullmatch(line) 666 if result: 667 properties[result.group("key")] = result.group("value") 668 details["android"] = properties 669 return details 670 671 def system_details(self) -> Dict[str, Any]: 672 if self._system_details: 673 return self._system_details 674 675 # TODO: Implement properly (i.e. remove all n/a values) 676 self._system_details = { 677 "machine": self.sh_stdout("uname", "-m").split()[0], 678 "os": { 679 "system": self.sh_stdout("uname", "-s").split()[0], 680 "release": self.sh_stdout("uname", "-r").split()[0], 681 "version": self.sh_stdout("uname", "-v").split()[0], 682 "platform": "n/a", 683 }, 684 "python": { 685 "version": "n/a", 686 "bits": "n/a", 687 }, 688 "CPU": self.cpu_details(), 689 "Android": self._getprop_system_details(), 690 } 691 return self._system_details 692 693 def screenshot(self, result_path: pth.AnyPath) -> None: 694 self.sh("screencap", "-p", result_path) 695