• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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