#!/usr/bin/env python # Copyright (c) 2012 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. """Provides an interface to communicate with the device via the adb command. Assumes adb binary is currently on system path. Usage: python android_commands.py wait-for-pm """ import collections import datetime import logging import optparse import os import pexpect import re import subprocess import sys import tempfile import time # adb_interface.py is under ../../third_party/android_testrunner/ sys.path.append(os.path.join(os.path.abspath(os.path.dirname(__file__)), '..', '..', 'third_party', 'android_testrunner')) import adb_interface import cmd_helper import errors # is under ../../third_party/android_testrunner/errors.py from run_tests_helper import IsRunningAsBuildbot # Pattern to search for the next whole line of pexpect output and capture it # into a match group. We can't use ^ and $ for line start end with pexpect, # see http://www.noah.org/python/pexpect/#doc for explanation why. PEXPECT_LINE_RE = re.compile('\n([^\r]*)\r') # Set the adb shell prompt to be a unique marker that will [hopefully] not # appear at the start of any line of a command's output. SHELL_PROMPT = '~+~PQ\x17RS~+~' # This only works for single core devices. SCALING_GOVERNOR = '/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor' DROP_CACHES = '/proc/sys/vm/drop_caches' # Java properties file LOCAL_PROPERTIES_PATH = '/data/local.prop' # Property in /data/local.prop that controls Java assertions. JAVA_ASSERT_PROPERTY = 'dalvik.vm.enableassertions' BOOT_COMPLETE_RE = re.compile( re.escape('android.intent.action.MEDIA_MOUNTED path: /mnt/sdcard') + '|' + re.escape('PowerManagerService: bootCompleted')) # Keycode "enum" suitable for passing to AndroidCommands.SendKey(). KEYCODE_DPAD_RIGHT = 22 KEYCODE_ENTER = 66 KEYCODE_MENU = 82 KEYCODE_BACK = 4 def GetEmulators(): """Returns a list of emulators. Does not filter by status (e.g. offline). Both devices starting with 'emulator' will be returned in below output: * daemon not running. starting it now on port 5037 * * daemon started successfully * List of devices attached 027c10494100b4d7 device emulator-5554 offline emulator-5558 device """ re_device = re.compile('^emulator-[0-9]+', re.MULTILINE) devices = re_device.findall(cmd_helper.GetCmdOutput(['adb', 'devices'])) return devices def GetAttachedDevices(): """Returns a list of attached, online android devices. If a preferred device has been set with ANDROID_SERIAL, it will be first in the returned list. Example output: * daemon not running. starting it now on port 5037 * * daemon started successfully * List of devices attached 027c10494100b4d7 device emulator-5554 offline """ re_device = re.compile('^([a-zA-Z0-9_:.-]+)\tdevice$', re.MULTILINE) devices = re_device.findall(cmd_helper.GetCmdOutput(['adb', 'devices'])) preferred_device = os.environ.get("ANDROID_SERIAL") if preferred_device in devices: devices.remove(preferred_device) devices.insert(0, preferred_device) return devices def _GetHostFileInfo(file_name): """Returns a tuple containing size and modified UTC time for file_name.""" # The time accuracy on device is only to minute level, remove the second and # microsecond from host results. utc_time = datetime.datetime.utcfromtimestamp(os.path.getmtime(file_name)) time_delta = datetime.timedelta(seconds=utc_time.second, microseconds=utc_time.microsecond) return os.path.getsize(file_name), utc_time - time_delta def ListHostPathContents(path): """Lists files in all subdirectories of |path|. Args: path: The path to list. Returns: A dict of {"name": (size, lastmod), ...}. """ if os.path.isfile(path): return {os.path.basename(path): _GetHostFileInfo(path)} ret = {} for root, dirs, files in os.walk(path): for d in dirs: if d.startswith('.'): dirs.remove(d) # Prune the dir for subsequent iterations. for f in files: if f.startswith('.'): continue full_file_name = os.path.join(root, f) file_name = os.path.relpath(full_file_name, path) ret[file_name] = _GetHostFileInfo(full_file_name) return ret def _GetFilesFromRecursiveLsOutput(path, ls_output, re_file, utc_offset=None): """Gets a list of files from `ls` command output. Python's os.walk isn't used because it doesn't work over adb shell. Args: path: The path to list. ls_output: A list of lines returned by an `ls -lR` command. re_file: A compiled regular expression which parses a line into named groups consisting of at minimum "filename", "date", "time", "size" and optionally "timezone". utc_offset: A 5-character string of the form +HHMM or -HHMM, where HH is a 2-digit string giving the number of UTC offset hours, and MM is a 2-digit string giving the number of UTC offset minutes. If the input utc_offset is None, will try to look for the value of "timezone" if it is specified in re_file. Returns: A dict of {"name": (size, lastmod), ...} where: name: The file name relative to |path|'s directory. size: The file size in bytes (0 for directories). lastmod: The file last modification date in UTC. """ re_directory = re.compile('^%s/(?P[^:]+):$' % re.escape(path)) path_dir = os.path.dirname(path) current_dir = '' files = {} for line in ls_output: directory_match = re_directory.match(line) if directory_match: current_dir = directory_match.group('dir') continue file_match = re_file.match(line) if file_match: filename = os.path.join(current_dir, file_match.group('filename')) if filename.startswith(path_dir): filename = filename[len(path_dir)+1:] lastmod = datetime.datetime.strptime( file_match.group('date') + ' ' + file_match.group('time')[:5], '%Y-%m-%d %H:%M') if not utc_offset and 'timezone' in re_file.groupindex: utc_offset = file_match.group('timezone') if isinstance(utc_offset, str) and len(utc_offset) == 5: utc_delta = datetime.timedelta(hours=int(utc_offset[1:3]), minutes=int(utc_offset[3:5])) if utc_offset[0:1] == '-': utc_delta = -utc_delta; lastmod -= utc_delta files[filename] = (int(file_match.group('size')), lastmod) return files def GetLogTimestamp(log_line): """Returns the timestamp of the given |log_line|.""" try: return datetime.datetime.strptime(log_line[:18], '%m-%d %H:%M:%S.%f') except (ValueError, IndexError): logging.critical('Error reading timestamp from ' + log_line) return None class AndroidCommands(object): """Helper class for communicating with Android device via adb. Args: device: If given, adb commands are only send to the device of this ID. Otherwise commands are sent to all attached devices. wait_for_pm: If true, issues an adb wait-for-device command. """ def __init__(self, device=None, wait_for_pm=False): self._adb = adb_interface.AdbInterface() if device: self._adb.SetTargetSerial(device) if wait_for_pm: self.WaitForDevicePm() self._logcat = None self._original_governor = None self._pushed_files = [] def Adb(self): """Returns our AdbInterface to avoid us wrapping all its methods.""" return self._adb def WaitForDevicePm(self): """Blocks until the device's package manager is available. To workaround http://b/5201039, we restart the shell and retry if the package manager isn't back after 120 seconds. Raises: errors.WaitForResponseTimedOutError after max retries reached. """ last_err = None retries = 3 while retries: try: self._adb.WaitForDevicePm() return # Success except errors.WaitForResponseTimedOutError as e: last_err = e logging.warning('Restarting and retrying after timeout: %s' % str(e)) retries -= 1 self.RestartShell() raise last_err # Only reached after max retries, re-raise the last error. def SynchronizeDateTime(self): """Synchronize date/time between host and device.""" self._adb.SendShellCommand('date -u %f' % time.time()) def RestartShell(self): """Restarts the shell on the device. Does not block for it to return.""" self.RunShellCommand('stop') self.RunShellCommand('start') def Reboot(self, full_reboot=True): """Reboots the device and waits for the package manager to return. Args: full_reboot: Whether to fully reboot the device or just restart the shell. """ # TODO(torne): hive can't reboot the device either way without breaking the # connection; work out if we can handle this better if os.environ.get('USING_HIVE'): logging.warning('Ignoring reboot request as we are on hive') return if full_reboot: self._adb.SendCommand('reboot') else: self.RestartShell() self.WaitForDevicePm() self.StartMonitoringLogcat(timeout=120) self.WaitForLogMatch(BOOT_COMPLETE_RE) self.UnlockDevice() def Uninstall(self, package): """Uninstalls the specified package from the device. Args: package: Name of the package to remove. """ uninstall_command = 'uninstall %s' % package logging.info('>>> $' + uninstall_command) self._adb.SendCommand(uninstall_command, timeout_time=60) def Install(self, package_file_path): """Installs the specified package to the device. Args: package_file_path: Path to .apk file to install. """ assert os.path.isfile(package_file_path) install_command = 'install %s' % package_file_path logging.info('>>> $' + install_command) self._adb.SendCommand(install_command, timeout_time=2*60) # It is tempting to turn this function into a generator, however this is not # possible without using a private (local) adb_shell instance (to ensure no # other command interleaves usage of it), which would defeat the main aim of # being able to reuse the adb shell instance across commands. def RunShellCommand(self, command, timeout_time=20, log_result=True): """Send a command to the adb shell and return the result. Args: command: String containing the shell command to send. Must not include the single quotes as we use them to escape the whole command. timeout_time: Number of seconds to wait for command to respond before retrying, used by AdbInterface.SendShellCommand. log_result: Boolean to indicate whether we should log the result of the shell command. Returns: list containing the lines of output received from running the command """ logging.info('>>> $' + command) if "'" in command: logging.warning(command + " contains ' quotes") result = self._adb.SendShellCommand("'%s'" % command, timeout_time).splitlines() if log_result: logging.info('\n>>> '.join(result)) return result def KillAll(self, process): """Android version of killall, connected via adb. Args: process: name of the process to kill off Returns: the number of processess killed """ pids = self.ExtractPid(process) if pids: self.RunShellCommand('kill ' + ' '.join(pids)) return len(pids) def StartActivity(self, package, activity, action='android.intent.action.VIEW', data=None, extras=None, trace_file_name=None): """Starts |package|'s activity on the device. Args: package: Name of package to start (e.g. 'com.android.chrome'). activity: Name of activity (e.g. '.Main' or 'com.android.chrome.Main'). data: Data string to pass to activity (e.g. 'http://www.example.com/'). extras: Dict of extras to pass to activity. trace_file_name: If used, turns on and saves the trace to this file name. """ cmd = 'am start -a %s -n %s/%s' % (action, package, activity) if data: cmd += ' -d "%s"' % data if extras: cmd += ' -e' for key in extras: cmd += ' %s %s' % (key, extras[key]) if trace_file_name: cmd += ' -S -P ' + trace_file_name self.RunShellCommand(cmd) def EnableAdbRoot(self): """Enable root on the device.""" self._adb.EnableAdbRoot() def CloseApplication(self, package): """Attempt to close down the application, using increasing violence. Args: package: Name of the process to kill off, e.g. com.android.chrome """ self.RunShellCommand('am force-stop ' + package) def ClearApplicationState(self, package): """Closes and clears all state for the given |package|.""" self.CloseApplication(package) self.RunShellCommand('rm -r /data/data/%s/cache/*' % package) self.RunShellCommand('rm -r /data/data/%s/files/*' % package) self.RunShellCommand('rm -r /data/data/%s/shared_prefs/*' % package) def SendKeyEvent(self, keycode): """Sends keycode to the device. Args: keycode: Numeric keycode to send (see "enum" at top of file). """ self.RunShellCommand('input keyevent %d' % keycode) def PushIfNeeded(self, local_path, device_path): """Pushes |local_path| to |device_path|. Works for files and directories. This method skips copying any paths in |test_data_paths| that already exist on the device with the same timestamp and size. All pushed files can be removed by calling RemovePushedFiles(). """ assert os.path.exists(local_path), 'Local path not found %s' % local_path self._pushed_files.append(device_path) # If the path contents are the same, there's nothing to do. local_contents = ListHostPathContents(local_path) device_contents = self.ListPathContents(device_path) # Only compare the size and timestamp if only copying a file because # the filename on device can be renamed. if os.path.isfile(local_path): assert len(local_contents) == 1 is_equal = local_contents.values() == device_contents.values() else: is_equal = local_contents == device_contents if is_equal: logging.info('%s is up-to-date. Skipping file push.' % device_path) return # They don't match, so remove everything first and then create it. if os.path.isdir(local_path): self.RunShellCommand('rm -r %s' % device_path, timeout_time=2*60) self.RunShellCommand('mkdir -p %s' % device_path) # NOTE: We can't use adb_interface.Push() because it hardcodes a timeout of # 60 seconds which isn't sufficient for a lot of users of this method. push_command = 'push %s %s' % (local_path, device_path) logging.info('>>> $' + push_command) output = self._adb.SendCommand(push_command, timeout_time=30*60) # Success looks like this: "3035 KB/s (12512056 bytes in 4.025s)" # Errors look like this: "failed to copy ... " if not re.search('^[0-9]', output): logging.critical('PUSH FAILED: ' + output) def GetFileContents(self, filename): """Gets contents from the file specified by |filename|.""" return self.RunShellCommand('if [ -f "' + filename + '" ]; then cat "' + filename + '"; fi') def SetFileContents(self, filename, contents): """Writes |contents| to the file specified by |filename|.""" with tempfile.NamedTemporaryFile() as f: f.write(contents) f.flush() self._adb.Push(f.name, filename) def RemovePushedFiles(self): """Removes all files pushed with PushIfNeeded() from the device.""" for p in self._pushed_files: self.RunShellCommand('rm -r %s' % p, timeout_time=2*60) def ListPathContents(self, path): """Lists files in all subdirectories of |path|. Args: path: The path to list. Returns: A dict of {"name": (size, lastmod), ...}. """ # Example output: # /foo/bar: # -rw-r----- 1 user group 102 2011-05-12 12:29:54.131623387 +0100 baz.txt re_file = re.compile('^-(?P[^\s]+)\s+' '(?P[^\s]+)\s+' '(?P[^\s]+)\s+' '(?P[^\s]+)\s+' '(?P[^\s]+)\s+' '(?P