diff options
Diffstat (limited to 'mojo/tools/mopy/android.py')
-rw-r--r-- | mojo/tools/mopy/android.py | 503 |
1 files changed, 323 insertions, 180 deletions
diff --git a/mojo/tools/mopy/android.py b/mojo/tools/mopy/android.py index 6584b9f..374ebb08 100644 --- a/mojo/tools/mopy/android.py +++ b/mojo/tools/mopy/android.py @@ -3,8 +3,13 @@ # found in the LICENSE file. import atexit +import datetime +import email.utils +import hashlib +import itertools import json import logging +import math import os import os.path import random @@ -17,9 +22,6 @@ import urlparse import SimpleHTTPServer import SocketServer -from mopy.config import Config -from mopy.paths import Paths - # Tags used by the mojo shell application logs. LOGCAT_TAGS = [ @@ -31,11 +33,30 @@ LOGCAT_TAGS = [ 'chromium', ] -ADB_PATH = os.path.join(Paths().src_root, 'third_party', 'android_tools', 'sdk', - 'platform-tools', 'adb') - MOJO_SHELL_PACKAGE_NAME = 'org.chromium.mojo.shell' +MAPPING_PREFIX = '--map-origin=' + +DEFAULT_BASE_PORT = 31337 + +ZERO = datetime.timedelta(0) + +class UTC_TZINFO(datetime.tzinfo): + """UTC time zone representation.""" + + def utcoffset(self, _): + return ZERO + + def tzname(self, _): + return "UTC" + + def dst(self, _): + return ZERO + +UTC = UTC_TZINFO() + +_logger = logging.getLogger() + class _SilentTCPServer(SocketServer.TCPServer): """ @@ -58,6 +79,69 @@ def _GetHandlerClassForPath(base_path): |base_path| directory over http. """ + def __init__(self, *args, **kwargs): + self.etag = None + SimpleHTTPServer.SimpleHTTPRequestHandler.__init__(self, *args, **kwargs) + + def get_etag(self): + if self.etag: + return self.etag + + path = self.translate_path(self.path) + if not os.path.isfile(path): + return None + + sha256 = hashlib.sha256() + BLOCKSIZE = 65536 + with open(path, 'rb') as hashed: + buf = hashed.read(BLOCKSIZE) + while len(buf) > 0: + sha256.update(buf) + buf = hashed.read(BLOCKSIZE) + self.etag = '"%s"' % sha256.hexdigest() + return self.etag + + def send_head(self): + # Always close the connection after each request, as the keep alive + # support from SimpleHTTPServer doesn't like when the client requests to + # close the connection before downloading the full response content. + # pylint: disable=W0201 + self.close_connection = 1 + + path = self.translate_path(self.path) + if os.path.isfile(path): + # Handle If-None-Match + etag = self.get_etag() + if ('If-None-Match' in self.headers and + etag == self.headers['If-None-Match']): + self.send_response(304) + return None + + # Handle If-Modified-Since + if ('If-None-Match' not in self.headers and + 'If-Modified-Since' in self.headers): + last_modified = datetime.datetime.fromtimestamp( + math.floor(os.stat(path).st_mtime), tz=UTC) + ims = datetime.datetime( + *email.utils.parsedate(self.headers['If-Modified-Since'])[:6], + tzinfo=UTC) + if last_modified <= ims: + self.send_response(304) + return None + + return SimpleHTTPServer.SimpleHTTPRequestHandler.send_head(self) + + def end_headers(self): + path = self.translate_path(self.path) + + if os.path.isfile(path): + etag = self.get_etag() + if etag: + self.send_header('ETag', etag) + self.send_header('Cache-Control', 'must-revalidate') + + return SimpleHTTPServer.SimpleHTTPRequestHandler.end_headers(self) + def translate_path(self, path): path_from_current = ( SimpleHTTPServer.SimpleHTTPRequestHandler.translate_path(self, path)) @@ -69,9 +153,26 @@ def _GetHandlerClassForPath(base_path): """ pass + RequestHandler.protocol_version = 'HTTP/1.1' return RequestHandler +def _IsMapOrigin(arg): + """Returns whether arg is a --map-origin argument.""" + return arg.startswith(MAPPING_PREFIX) + + +def _Split(l, pred): + positive = [] + negative = [] + for v in l: + if pred(v): + positive.append(v) + else: + negative.append(v) + return (positive, negative) + + def _ExitIfNeeded(process): """ Exits |process| if it is still alive. @@ -80,186 +181,228 @@ def _ExitIfNeeded(process): process.kill() -def _ReadFifo(fifo_path, pipe, on_fifo_closed, max_attempts=5): - """ - Reads |fifo_path| on the device and write the contents to |pipe|. Calls - |on_fifo_closed| when the fifo is closed. This method will try to find the - path up to |max_attempts|, waiting 1 second between each attempt. If it cannot - find |fifo_path|, a exception will be raised. +class AndroidShell(object): + """ Allows to set up and run a given mojo shell binary on an Android device. + + Args: + shell_apk_path: path to the shell Android binary + local_dir: directory where locally build Mojo apps will be served, optional + adb_path: path to adb, optional if adb is in PATH + target_device: device to run on, if multiple devices are connected """ - def Run(): - def _WaitForFifo(): - command = [ADB_PATH, 'shell', 'test -e "%s"; echo $?' % fifo_path] - for _ in xrange(max_attempts): - if subprocess.check_output(command)[0] == '0': - return - time.sleep(1) + def __init__( + self, shell_apk_path, local_dir=None, adb_path="adb", target_device=None): + self.shell_apk_path = shell_apk_path + self.adb_path = adb_path + self.local_dir = local_dir + self.target_device = target_device + + def _CreateADBCommand(self, args): + adb_command = [self.adb_path] + if self.target_device: + adb_command.extend(['-s', self.target_device]) + adb_command.extend(args) + return adb_command + + def _ReadFifo(self, fifo_path, pipe, on_fifo_closed, max_attempts=5): + """ + Reads |fifo_path| on the device and write the contents to |pipe|. Calls + |on_fifo_closed| when the fifo is closed. This method will try to find the + path up to |max_attempts|, waiting 1 second between each attempt. If it + cannot find |fifo_path|, a exception will be raised. + """ + fifo_command = self._CreateADBCommand( + ['shell', 'test -e "%s"; echo $?' % fifo_path]) + + def Run(): + def _WaitForFifo(): + for _ in xrange(max_attempts): + if subprocess.check_output(fifo_command)[0] == '0': + return + time.sleep(1) + if on_fifo_closed: + on_fifo_closed() + raise Exception("Unable to find fifo.") + _WaitForFifo() + stdout_cat = subprocess.Popen(self._CreateADBCommand([ + 'shell', + 'cat', + fifo_path]), + stdout=pipe) + atexit.register(_ExitIfNeeded, stdout_cat) + stdout_cat.wait() if on_fifo_closed: on_fifo_closed() - raise Exception("Unable to find fifo.") - _WaitForFifo() - stdout_cat = subprocess.Popen([ADB_PATH, - 'shell', - 'cat', - fifo_path], - stdout=pipe) - atexit.register(_ExitIfNeeded, stdout_cat) - stdout_cat.wait() - if on_fifo_closed: - on_fifo_closed() - - thread = threading.Thread(target=Run, name="StdoutRedirector") - thread.start() - - -def _MapPort(device_port, host_port): - """ - Maps the device port to the host port. If |device_port| is 0, a random - available port is chosen. Returns the device port. - """ - def _FindAvailablePortOnDevice(): - opened = subprocess.check_output([ADB_PATH, 'shell', 'netstat']) - opened = [int(x.strip().split()[3].split(':')[1]) - for x in opened if x.startswith(' tcp')] - while True: - port = random.randint(4096, 16384) - if port not in opened: - return port - if device_port == 0: - device_port = _FindAvailablePortOnDevice() - subprocess.Popen([ADB_PATH, - "reverse", - "tcp:%d" % device_port, - "tcp:%d" % host_port]).wait() - def _UnmapPort(): - subprocess.Popen([ADB_PATH, "reverse", "--remove", "tcp:%d" % device_port]) - atexit.register(_UnmapPort) - return device_port - - -def StartHttpServerForDirectory(path): - """Starts an http server serving files from |path|. Returns the local url.""" - print 'starting http for', path - httpd = _SilentTCPServer(('127.0.0.1', 0), _GetHandlerClassForPath(path)) - atexit.register(httpd.shutdown) - - http_thread = threading.Thread(target=httpd.serve_forever) - http_thread.daemon = True - http_thread.start() - - print 'local port=', httpd.server_address[1] - return 'http://127.0.0.1:%d/' % _MapPort(0, httpd.server_address[1]) - - -def PrepareShellRun(config, origin=None): - """ Prepares for StartShell: runs adb as root and installs the apk. If no - --origin is specified, local http server will be set up to serve files from - the build directory along with port forwarding. - - Returns arguments that should be appended to shell argument list.""" - build_dir = Paths(config).build_dir - - subprocess.check_call([ADB_PATH, 'root']) - apk_path = os.path.join(build_dir, 'apks', 'MojoShell.apk') - subprocess.check_call( - [ADB_PATH, 'install', '-r', apk_path, '-i', MOJO_SHELL_PACKAGE_NAME]) - atexit.register(StopShell) - - extra_shell_args = [] - origin_url = origin if origin else StartHttpServerForDirectory(build_dir) - extra_shell_args.append("--origin=" + origin_url) - - return extra_shell_args - - -def _StartHttpServerForOriginMapping(mapping): - """If |mapping| points at a local file starts an http server to serve files - from the directory and returns the new mapping. - - This is intended to be called for every --map-origin value.""" - parts = mapping.split('=') - if len(parts) != 2: - return mapping - dest = parts[1] - # If the destination is a url, don't map it. - if urlparse.urlparse(dest)[0]: - return mapping - # Assume the destination is a local file. Start a local server that redirects - # to it. - localUrl = StartHttpServerForDirectory(dest) - print 'started server at %s for %s' % (dest, localUrl) - return parts[0] + '=' + localUrl - - -def _StartHttpServerForOriginMappings(arg): - """Calls _StartHttpServerForOriginMapping for every --map-origin argument.""" - mapping_prefix = '--map-origin=' - if not arg.startswith(mapping_prefix): - return arg - return mapping_prefix + ','.join([_StartHttpServerForOriginMapping(value) - for value in arg[len(mapping_prefix):].split(',')]) - - -def StartShell(arguments, stdout=None, on_application_stop=None): - """ - Starts the mojo shell, passing it the given arguments. - The |arguments| list must contain the "--origin=" arg from PrepareShellRun. - If |stdout| is not None, it should be a valid argument for subprocess.Popen. - """ - STDOUT_PIPE = "/data/data/%s/stdout.fifo" % MOJO_SHELL_PACKAGE_NAME - - cmd = [ADB_PATH, - 'shell', - 'am', - 'start', - '-W', - '-S', - '-a', 'android.intent.action.VIEW', - '-n', '%s/.MojoShellActivity' % MOJO_SHELL_PACKAGE_NAME] - - parameters = [] - if stdout or on_application_stop: - subprocess.check_call([ADB_PATH, 'shell', 'rm', STDOUT_PIPE]) - parameters.append('--fifo-path=%s' % STDOUT_PIPE) - _ReadFifo(STDOUT_PIPE, stdout, on_application_stop) - # The origin has to be specified whether it's local or external. - assert any("--origin=" in arg for arg in arguments) - parameters += [_StartHttpServerForOriginMappings(arg) for arg in arguments] - - if parameters: - encodedParameters = json.dumps(parameters) - cmd += [ '--es', 'encodedParameters', encodedParameters] - - with open(os.devnull, 'w') as devnull: - subprocess.Popen(cmd, stdout=devnull).wait() - - -def StopShell(): - """ - Stops the mojo shell. - """ - subprocess.check_call( - [ADB_PATH, 'shell', 'am', 'force-stop', MOJO_SHELL_PACKAGE_NAME]) + thread = threading.Thread(target=Run, name="StdoutRedirector") + thread.start() + def _MapPort(self, device_port, host_port): + """ + Maps the device port to the host port. If |device_port| is 0, a random + available port is chosen. Returns the device port. + """ + def _FindAvailablePortOnDevice(): + opened = subprocess.check_output( + self._CreateADBCommand(['shell', 'netstat'])) + opened = [int(x.strip().split()[3].split(':')[1]) + for x in opened if x.startswith(' tcp')] + while True: + port = random.randint(4096, 16384) + if port not in opened: + return port + if device_port == 0: + device_port = _FindAvailablePortOnDevice() + subprocess.Popen(self._CreateADBCommand([ + "reverse", + "tcp:%d" % device_port, + "tcp:%d" % host_port])).wait() + + unmap_command = self._CreateADBCommand(["reverse", "--remove", + "tcp:%d" % device_port]) + + def _UnmapPort(): + subprocess.Popen(unmap_command) + atexit.register(_UnmapPort) + return device_port + + def _StartHttpServerForDirectory(self, path, port=0): + """Starts an http server serving files from |path|. Returns the local + url.""" + assert path + print 'starting http for', path + httpd = _SilentTCPServer(('127.0.0.1', 0), _GetHandlerClassForPath(path)) + atexit.register(httpd.shutdown) + + http_thread = threading.Thread(target=httpd.serve_forever) + http_thread.daemon = True + http_thread.start() + + print 'local port=%d' % httpd.server_address[1] + return 'http://127.0.0.1:%d/' % self._MapPort(port, httpd.server_address[1]) + + def _StartHttpServerForOriginMapping(self, mapping, port): + """If |mapping| points at a local file starts an http server to serve files + from the directory and returns the new mapping. + + This is intended to be called for every --map-origin value.""" + parts = mapping.split('=') + if len(parts) != 2: + return mapping + dest = parts[1] + # If the destination is a url, don't map it. + if urlparse.urlparse(dest)[0]: + return mapping + # Assume the destination is a local file. Start a local server that + # redirects to it. + localUrl = self._StartHttpServerForDirectory(dest, port) + print 'started server at %s for %s' % (dest, localUrl) + return parts[0] + '=' + localUrl + + def _StartHttpServerForOriginMappings(self, map_parameters, fixed_port): + """Calls _StartHttpServerForOriginMapping for every --map-origin + argument.""" + if not map_parameters: + return [] + + original_values = list(itertools.chain( + *map(lambda x: x[len(MAPPING_PREFIX):].split(','), map_parameters))) + sorted(original_values) + result = [] + for i, value in enumerate(original_values): + result.append(self._StartHttpServerForOriginMapping( + value, DEFAULT_BASE_PORT + 1 + i if fixed_port else 0)) + return [MAPPING_PREFIX + ','.join(result)] + + def PrepareShellRun(self, origin=None, fixed_port=True): + """ Prepares for StartShell: runs adb as root and installs the apk. If no + --origin is specified, local http server will be set up to serve files from + the build directory along with port forwarding. + + Returns arguments that should be appended to shell argument list.""" + if 'cannot run as root' in subprocess.check_output( + self._CreateADBCommand(['root'])): + raise Exception("Unable to run adb as root.") + subprocess.check_call( + self._CreateADBCommand(['install', '-r', self.shell_apk_path, '-i', + MOJO_SHELL_PACKAGE_NAME])) + atexit.register(self.StopShell) + + extra_shell_args = [] + origin_url = origin if origin else self._StartHttpServerForDirectory( + self.local_dir, DEFAULT_BASE_PORT if fixed_port else 0) + extra_shell_args.append("--origin=" + origin_url) + + return extra_shell_args + + def StartShell(self, + arguments, + stdout=None, + on_application_stop=None, + fixed_port=True): + """ + Starts the mojo shell, passing it the given arguments. -def CleanLogs(): - """ - Cleans the logs on the device. - """ - subprocess.check_call([ADB_PATH, 'logcat', '-c']) + The |arguments| list must contain the "--origin=" arg from PrepareShellRun. + If |stdout| is not None, it should be a valid argument for subprocess.Popen. + """ + STDOUT_PIPE = "/data/data/%s/stdout.fifo" % MOJO_SHELL_PACKAGE_NAME + + cmd = self._CreateADBCommand([ + 'shell', + 'am', + 'start', + '-S', + '-a', 'android.intent.action.VIEW', + '-n', '%s/.MojoShellActivity' % MOJO_SHELL_PACKAGE_NAME]) + + parameters = [] + if stdout or on_application_stop: + subprocess.check_call(self._CreateADBCommand( + ['shell', 'rm', STDOUT_PIPE])) + parameters.append('--fifo-path=%s' % STDOUT_PIPE) + self._ReadFifo(STDOUT_PIPE, stdout, on_application_stop) + # The origin has to be specified whether it's local or external. + assert any("--origin=" in arg for arg in arguments) + + # Extract map-origin arguments. + map_parameters, other_parameters = _Split(arguments, _IsMapOrigin) + parameters += other_parameters + parameters += self._StartHttpServerForOriginMappings(map_parameters, + fixed_port) + + if parameters: + encodedParameters = json.dumps(parameters) + cmd += ['--es', 'encodedParameters', encodedParameters] + + with open(os.devnull, 'w') as devnull: + subprocess.Popen(cmd, stdout=devnull).wait() + + def StopShell(self): + """ + Stops the mojo shell. + """ + subprocess.check_call(self._CreateADBCommand(['shell', + 'am', + 'force-stop', + MOJO_SHELL_PACKAGE_NAME])) + def CleanLogs(self): + """ + Cleans the logs on the device. + """ + subprocess.check_call(self._CreateADBCommand(['logcat', '-c'])) -def ShowLogs(): - """ - Displays the log for the mojo shell. + def ShowLogs(self): + """ + Displays the log for the mojo shell. - Returns the process responsible for reading the logs. - """ - logcat = subprocess.Popen([ADB_PATH, - 'logcat', - '-s', - ' '.join(LOGCAT_TAGS)], - stdout=sys.stdout) - atexit.register(_ExitIfNeeded, logcat) - return logcat + Returns the process responsible for reading the logs. + """ + logcat = subprocess.Popen(self._CreateADBCommand([ + 'logcat', + '-s', + ' '.join(LOGCAT_TAGS)]), + stdout=sys.stdout) + atexit.register(_ExitIfNeeded, logcat) + return logcat |