From c134c467a4140707b3f08c35db4abc151974a883 Mon Sep 17 00:00:00 2001 From: "craigdh@chromium.org" Date: Mon, 2 Dec 2013 17:58:10 +0000 Subject: [PATCH] [android] Adds AdbWrapper which is the base of the eventual AndroidCommands replacement. BUG=318387 TEST=included NOTRY=True Review URL: https://codereview.chromium.org/64553012 git-svn-id: http://src.chromium.org/svn/trunk/src/build@238123 4ff67af0-8c30-449e-8e8b-ad334ec8d88c --- android/pylib/device/__init__.py | 0 android/pylib/device/adb_wrapper.py | 423 +++++++++++++++++++++++ android/pylib/device/adb_wrapper_test.py | 90 +++++ 3 files changed, 513 insertions(+) create mode 100644 android/pylib/device/__init__.py create mode 100644 android/pylib/device/adb_wrapper.py create mode 100644 android/pylib/device/adb_wrapper_test.py diff --git a/android/pylib/device/__init__.py b/android/pylib/device/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/android/pylib/device/adb_wrapper.py b/android/pylib/device/adb_wrapper.py new file mode 100644 index 000000000..05445aaa2 --- /dev/null +++ b/android/pylib/device/adb_wrapper.py @@ -0,0 +1,423 @@ +# Copyright 2013 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +"""This module wraps Android's adb tool. + +This is a thin wrapper around the adb interface. Any additional complexity +should be delegated to a higher level (ex. DeviceUtils). +""" + +import errno +import logging +import os + +from pylib import cmd_helper + +from pylib.utils import reraiser_thread +from pylib.utils import timeout_retry + +_DEFAULT_TIMEOUT = 30 +_DEFAULT_RETRIES = 2 + + +class BaseError(Exception): + """Base exception for all device and command errors.""" + pass + + +class CommandFailedError(BaseError): + """Exception for command failures.""" + + def __init__(self, cmd, msg, device=None): + super(CommandFailedError, self).__init__( + (('device %s: ' % device) if device else '') + + 'adb command \'%s\' failed with message: \'%s\'' % (' '.join(cmd), msg)) + + +class CommandTimeoutError(BaseError): + """Exception for command timeouts.""" + pass + + +def _VerifyLocalFileExists(path): + """Verifies a local file exists. + + Args: + path: Path to the local file. + + Raises: + IOError: If the file doesn't exist. + """ + if not os.path.exists(path): + raise IOError(errno.ENOENT, os.strerror(errno.ENOENT), path) + + +class AdbWrapper(object): + """A wrapper around a local Android Debug Bridge executable.""" + + def __init__(self, device_serial): + """Initializes the AdbWrapper. + + Args: + device_serial: The device serial number as a string. + """ + self._device_serial = str(device_serial) + + @classmethod + def _AdbCmd(cls, arg_list, timeout, retries, check_error=True): + """Runs an adb command with a timeout and retries. + + Args: + arg_list: A list of arguments to adb. + timeout: Timeout in seconds. + retries: Number of retries. + check_error: Check that the command doesn't return an error message. This + does NOT check the return code of shell commands. + + Returns: + The output of the command. + """ + cmd = ['adb'] + arg_list + + # This method runs inside the timeout/retries. + def RunCmd(): + exit_code, output = cmd_helper.GetCmdStatusAndOutput(cmd) + if exit_code != 0: + raise CommandFailedError( + cmd, 'returned non-zero exit code %s, output: %s' % + (exit_code, output)) + # This catches some errors, including when the device drops offline; + # unfortunately adb is very inconsistent with error reporting so many + # command failures present differently. + if check_error and output[:len('error:')] == 'error:': + raise CommandFailedError(arg_list, output) + return output + + try: + return timeout_retry.Run(RunCmd, timeout, retries) + except reraiser_thread.TimeoutError as e: + raise CommandTimeoutError(str(e)) + + def _DeviceAdbCmd(self, arg_list, timeout, retries, check_error=True): + """Runs an adb command on the device associated with this object. + + Args: + arg_list: A list of arguments to adb. + timeout: Timeout in seconds. + retries: Number of retries. + check_error: Check that the command doesn't return an error message. This + does NOT check the return code of shell commands. + + Returns: + The output of the command. + """ + return self._AdbCmd( + ['-s', self._device_serial] + arg_list, timeout, retries, + check_error=check_error) + + def __eq__(self, other): + """Consider instances equal if they refer to the same device. + + Args: + other: The instance to compare equality with. + + Returns: + True if the instances are considered equal, false otherwise. + """ + return self._device_serial == str(other) + + def __str__(self): + """The string representation of an instance. + + Returns: + The device serial number as a string. + """ + return self._device_serial + + def __repr__(self): + return '%s(\'%s\')' % (self.__class__.__name__, self) + + # TODO(craigdh): Determine the filter criteria that should be supported. + @classmethod + def GetDevices(cls, timeout=_DEFAULT_TIMEOUT, retries=_DEFAULT_RETRIES): + """Get the list of active attached devices. + + Args: + timeout: (optional) Timeout per try in seconds. + retries: (optional) Number of retries to attempt. + + Yields: + AdbWrapper instances. + """ + output = cls._AdbCmd(['devices'], timeout, retries) + lines = [line.split() for line in output.split('\n')] + return [AdbWrapper(line[0]) for line in lines + if len(line) == 2 and line[1] == 'device'] + + def GetDeviceSerial(self): + """Gets the device serial number associated with this object. + + Returns: + Device serial number as a string. + """ + return self._device_serial + + def Push(self, local, remote, timeout=60*5, retries=_DEFAULT_RETRIES): + """Pushes a file from the host to the device. + + Args: + local: Path on the host filesystem. + remote: Path on the device filesystem. + timeout: (optional) Timeout per try in seconds. + retries: (optional) Number of retries to attempt. + """ + _VerifyLocalFileExists(local) + self._DeviceAdbCmd(['push', local, remote], timeout, retries) + + def Pull(self, remote, local, timeout=60*5, retries=_DEFAULT_RETRIES): + """Pulls a file from the device to the host. + + Args: + remote: Path on the device filesystem. + local: Path on the host filesystem. + timeout: (optional) Timeout per try in seconds. + retries: (optional) Number of retries to attempt. + """ + self._DeviceAdbCmd(['pull', remote, local], timeout, retries) + _VerifyLocalFileExists(local) + + def Shell(self, command, expect_rc=None, timeout=_DEFAULT_TIMEOUT, + retries=_DEFAULT_RETRIES): + """Runs a shell command on the device. + + Args: + command: The shell command to run. + expect_rc: (optional) If set checks that the command's return code matches + this value. + timeout: (optional) Timeout per try in seconds. + retries: (optional) Number of retries to attempt. + + Returns: + The output of the shell command as a string. + + Raises: + CommandFailedError: If the return code doesn't match |expect_rc|. + """ + if expect_rc is None: + actual_command = command + else: + actual_command = '%s; echo $?;' % command + output = self._DeviceAdbCmd( + ['shell', actual_command], timeout, retries, check_error=False) + if expect_rc is not None: + output_end = output.rstrip().rfind('\n') + 1 + rc = output[output_end:].strip() + output = output[:output_end] + if int(rc) != expect_rc: + raise CommandFailedError( + ['shell', command], + 'shell command exited with code: %s' % rc, + self._device_serial) + return output + + def Logcat(self, filter_spec=None, timeout=_DEFAULT_TIMEOUT, + retries=_DEFAULT_RETRIES): + """Get the logcat output. + + Args: + filter_spec: (optional) Spec to filter the logcat. + timeout: (optional) Timeout per try in seconds. + retries: (optional) Number of retries to attempt. + + Returns: + logcat output as a string. + """ + cmd = ['logcat'] + if filter_spec is not None: + cmd.append(filter_spec) + return self._DeviceAdbCmd(cmd, timeout, retries, check_error=False) + + def Forward(self, local, remote, timeout=_DEFAULT_TIMEOUT, + retries=_DEFAULT_RETRIES): + """Forward socket connections from the local socket to the remote socket. + + Sockets are specified by one of: + tcp: + localabstract: + localreserved: + localfilesystem: + dev: + jdwp: (remote only) + + Args: + local: The host socket. + remote: The device socket. + timeout: (optional) Timeout per try in seconds. + retries: (optional) Number of retries to attempt. + """ + self._DeviceAdbCmd(['forward', str(local), str(remote)], timeout, retries) + + def JDWP(self, timeout=_DEFAULT_TIMEOUT, retries=_DEFAULT_RETRIES): + """List of PIDs of processes hosting a JDWP transport. + + Args: + timeout: (optional) Timeout per try in seconds. + retries: (optional) Number of retries to attempt. + + Returns: + A list of PIDs as strings. + """ + return [a.strip() for a in + self._DeviceAdbCmd(['jdwp'], timeout, retries).split('\n')] + + def Install(self, apk_path, forward_lock=False, reinstall=False, + sd_card=False, timeout=60*2, retries=_DEFAULT_RETRIES): + """Install an apk on the device. + + Args: + apk_path: Host path to the APK file. + forward_lock: (optional) If set forward-locks the app. + reinstall: (optional) If set reinstalls the app, keeping its data. + sd_card: (optional) If set installs on the SD card. + timeout: (optional) Timeout per try in seconds. + retries: (optional) Number of retries to attempt. + """ + _VerifyLocalFileExists(apk_path) + cmd = ['install'] + if forward_lock: + cmd.append('-l') + if reinstall: + cmd.append('-r') + if sd_card: + cmd.append('-s') + cmd.append(apk_path) + output = self._DeviceAdbCmd(cmd, timeout, retries) + if 'Success' not in output: + raise CommandFailedError(cmd, output) + + def Uninstall(self, package, keep_data=False, timeout=_DEFAULT_TIMEOUT, + retries=_DEFAULT_RETRIES): + """Remove the app |package| from the device. + + Args: + package: The package to uninstall. + keep_data: (optional) If set keep the data and cache directories. + timeout: (optional) Timeout per try in seconds. + retries: (optional) Number of retries to attempt. + """ + cmd = ['uninstall'] + if keep_data: + cmd.append('-k') + cmd.append(package) + output = self._DeviceAdbCmd(cmd, timeout, retries) + if 'Failure' in output: + raise CommandFailedError(cmd, output) + + def Backup(self, path, packages=None, apk=False, shared=False, + nosystem=True, include_all=False, timeout=_DEFAULT_TIMEOUT, + retries=_DEFAULT_RETRIES): + """Write an archive of the device's data to |path|. + + Args: + path: Local path to store the backup file. + packages: List of to packages to be backed up. + apk: (optional) If set include the .apk files in the archive. + shared: (optional) If set buckup the device's SD card. + nosystem: (optional) If set exclude system applications. + include_all: (optional) If set back up all installed applications and + |packages| is optional. + timeout: (optional) Timeout per try in seconds. + retries: (optional) Number of retries to attempt. + """ + cmd = ['backup', path] + if apk: + cmd.append('-apk') + if shared: + cmd.append('-shared') + if nosystem: + cmd.append('-nosystem') + if include_all: + cmd.append('-all') + if packages: + cmd.extend(packages) + assert bool(packages) ^ bool(include_all), ( + 'Provide \'packages\' or set \'include_all\' but not both.') + ret = self._DeviceAdbCmd(cmd, timeout, retries) + _VerifyLocalFileExists(path) + return ret + + def Restore(self, path, timeout=_DEFAULT_TIMEOUT, retries=_DEFAULT_RETRIES): + """Restore device contents from the backup archive. + + Args: + path: Host path to the backup archive. + timeout: (optional) Timeout per try in seconds. + retries: (optional) Number of retries to attempt. + """ + _VerifyLocalFileExists(path) + self._DeviceAdbCmd(['restore'] + [path], timeout, retries) + + def WaitForDevice(self, timeout=60*5, retries=_DEFAULT_RETRIES): + """Block until the device is online. + + Args: + timeout: (optional) Timeout per try in seconds. + retries: (optional) Number of retries to attempt. + """ + self._DeviceAdbCmd(['wait-for-device'], timeout, retries) + + def GetState(self, timeout=_DEFAULT_TIMEOUT, retries=_DEFAULT_RETRIES): + """Get device state. + + Args: + timeout: (optional) Timeout per try in seconds. + retries: (optional) Number of retries to attempt. + + Returns: + One of 'offline', 'bootloader', or 'device'. + """ + return self._DeviceAdbCmd(['get-state'], timeout, retries).strip() + + def GetDevPath(self, timeout=_DEFAULT_TIMEOUT, retries=_DEFAULT_RETRIES): + """Gets the device path. + + Args: + timeout: (optional) Timeout per try in seconds. + retries: (optional) Number of retries to attempt. + + Returns: + The device path (e.g. usb:3-4) + """ + return self._DeviceAdbCmd(['get-devpath'], timeout, retries) + + def Remount(self, timeout=_DEFAULT_TIMEOUT, retries=_DEFAULT_RETRIES): + """Remounts the /system partition on the device read-write.""" + self._DeviceAdbCmd(['remount'], timeout, retries) + + def Reboot(self, to_bootloader=False, timeout=60*5, + retries=_DEFAULT_RETRIES): + """Reboots the device. + + Args: + to_bootloader: (optional) If set reboots to the bootloader. + timeout: (optional) Timeout per try in seconds. + retries: (optional) Number of retries to attempt. + """ + if to_bootloader: + cmd = ['reboot-bootloader'] + else: + cmd = ['reboot'] + self._DeviceAdbCmd(cmd, timeout, retries) + + def Root(self, timeout=_DEFAULT_TIMEOUT, retries=_DEFAULT_RETRIES): + """Restarts the adbd daemon with root permissions, if possible. + + Args: + timeout: (optional) Timeout per try in seconds. + retries: (optional) Number of retries to attempt. + """ + output = self._DeviceAdbCmd(['root'], timeout, retries) + if 'cannot' in output: + raise CommandFailedError(['root'], output) + diff --git a/android/pylib/device/adb_wrapper_test.py b/android/pylib/device/adb_wrapper_test.py new file mode 100644 index 000000000..1e533c477 --- /dev/null +++ b/android/pylib/device/adb_wrapper_test.py @@ -0,0 +1,90 @@ +# Copyright 2013 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +"""Tests for the AdbWrapper class.""" + +import os +import socket +import tempfile +import time +import unittest + +import adb_wrapper + + +class TestAdbWrapper(unittest.TestCase): + + def setUp(self): + devices = adb_wrapper.AdbWrapper.GetDevices() + assert devices, 'A device must be attached' + self._adb = devices[0] + self._adb.WaitForDevice() + + def _MakeTempFile(self, contents): + """Make a temporary file with the given contents. + + Args: + contents: string to write to the temporary file. + + Returns: + The absolute path to the file. + """ + fi, path = tempfile.mkstemp() + with os.fdopen(fi, 'wb') as f: + f.write('foo') + return path + + def testShell(self): + output = self._adb.Shell('echo test', expect_rc=0) + self.assertEqual(output.strip(), 'test') + output = self._adb.Shell('echo test') + self.assertEqual(output.strip(), 'test') + self.assertRaises(adb_wrapper.CommandFailedError, self._adb.Shell, + 'echo test', expect_rc=1) + + def testPushPull(self): + path = self._MakeTempFile('foo') + device_path = '/data/local/tmp/testfile.txt' + local_tmpdir = os.path.dirname(path) + self._adb.Push(path, device_path) + self.assertEqual(self._adb.Shell('cat %s' % device_path), 'foo') + self._adb.Pull(device_path, local_tmpdir) + with open(os.path.join(local_tmpdir, 'testfile.txt'), 'r') as f: + self.assertEqual(f.read(), 'foo') + + def testInstall(self): + path = self._MakeTempFile('foo') + self.assertRaises(adb_wrapper.CommandFailedError, self._adb.Install, path) + + def testForward(self): + self.assertRaises(adb_wrapper.CommandFailedError, self._adb.Forward, 0, 0) + + def testUninstall(self): + self.assertRaises(adb_wrapper.CommandFailedError, self._adb.Uninstall, + 'some.nonexistant.package') + + def testRebootWaitForDevice(self): + self._adb.Reboot() + print 'waiting for device to reboot...' + while self._adb.GetState() == 'device': + time.sleep(1) + self._adb.WaitForDevice() + self.assertEqual(self._adb.GetState(), 'device') + print 'waiting for package manager...' + while 'package:' not in self._adb.Shell('pm path android'): + time.sleep(1) + + def testRootRemount(self): + self._adb.Root() + while True: + try: + self._adb.Shell('start') + break + except adb_wrapper.CommandFailedError: + time.sleep(1) + self._adb.Remount() + + +if __name__ == '__main__': + unittest.main()