#!/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