| OLD | NEW |
| (Empty) |
| 1 # Copyright 2014 The Chromium Authors. All rights reserved. | |
| 2 # Use of this source code is governed by a BSD-style license that can be | |
| 3 # found in the LICENSE file. | |
| 4 | |
| 5 import atexit | |
| 6 import datetime | |
| 7 import email.utils | |
| 8 import hashlib | |
| 9 import itertools | |
| 10 import json | |
| 11 import logging | |
| 12 import math | |
| 13 import os | |
| 14 import os.path | |
| 15 import random | |
| 16 import subprocess | |
| 17 import sys | |
| 18 import threading | |
| 19 import time | |
| 20 import urlparse | |
| 21 | |
| 22 import SimpleHTTPServer | |
| 23 import SocketServer | |
| 24 | |
| 25 | |
| 26 # Tags used by the mojo shell application logs. | |
| 27 LOGCAT_TAGS = [ | |
| 28 'AndroidHandler', | |
| 29 'MojoFileHelper', | |
| 30 'MojoMain', | |
| 31 'MojoShellActivity', | |
| 32 'MojoShellApplication', | |
| 33 'chromium', | |
| 34 ] | |
| 35 | |
| 36 MOJO_SHELL_PACKAGE_NAME = 'org.chromium.mojo.shell' | |
| 37 | |
| 38 MAPPING_PREFIX = '--map-origin=' | |
| 39 | |
| 40 DEFAULT_BASE_PORT = 31337 | |
| 41 | |
| 42 ZERO = datetime.timedelta(0) | |
| 43 | |
| 44 class UTC_TZINFO(datetime.tzinfo): | |
| 45 """UTC time zone representation.""" | |
| 46 | |
| 47 def utcoffset(self, _): | |
| 48 return ZERO | |
| 49 | |
| 50 def tzname(self, _): | |
| 51 return "UTC" | |
| 52 | |
| 53 def dst(self, _): | |
| 54 return ZERO | |
| 55 | |
| 56 UTC = UTC_TZINFO() | |
| 57 | |
| 58 _logger = logging.getLogger() | |
| 59 | |
| 60 | |
| 61 class _SilentTCPServer(SocketServer.TCPServer): | |
| 62 """ | |
| 63 A TCPServer that won't display any error, unless debugging is enabled. This is | |
| 64 useful because the client might stop while it is fetching an URL, which causes | |
| 65 spurious error messages. | |
| 66 """ | |
| 67 def handle_error(self, request, client_address): | |
| 68 """ | |
| 69 Override the base class method to have conditional logging. | |
| 70 """ | |
| 71 if logging.getLogger().isEnabledFor(logging.DEBUG): | |
| 72 SocketServer.TCPServer.handle_error(self, request, client_address) | |
| 73 | |
| 74 | |
| 75 def _GetHandlerClassForPath(base_path): | |
| 76 class RequestHandler(SimpleHTTPServer.SimpleHTTPRequestHandler): | |
| 77 """ | |
| 78 Handler for SocketServer.TCPServer that will serve the files from | |
| 79 |base_path| directory over http. | |
| 80 """ | |
| 81 | |
| 82 def __init__(self, *args, **kwargs): | |
| 83 self.etag = None | |
| 84 SimpleHTTPServer.SimpleHTTPRequestHandler.__init__(self, *args, **kwargs) | |
| 85 | |
| 86 def get_etag(self): | |
| 87 if self.etag: | |
| 88 return self.etag | |
| 89 | |
| 90 path = self.translate_path(self.path) | |
| 91 if not os.path.isfile(path): | |
| 92 return None | |
| 93 | |
| 94 sha256 = hashlib.sha256() | |
| 95 BLOCKSIZE = 65536 | |
| 96 with open(path, 'rb') as hashed: | |
| 97 buf = hashed.read(BLOCKSIZE) | |
| 98 while len(buf) > 0: | |
| 99 sha256.update(buf) | |
| 100 buf = hashed.read(BLOCKSIZE) | |
| 101 self.etag = '"%s"' % sha256.hexdigest() | |
| 102 return self.etag | |
| 103 | |
| 104 def send_head(self): | |
| 105 # Always close the connection after each request, as the keep alive | |
| 106 # support from SimpleHTTPServer doesn't like when the client requests to | |
| 107 # close the connection before downloading the full response content. | |
| 108 # pylint: disable=W0201 | |
| 109 self.close_connection = 1 | |
| 110 | |
| 111 path = self.translate_path(self.path) | |
| 112 if os.path.isfile(path): | |
| 113 # Handle If-None-Match | |
| 114 etag = self.get_etag() | |
| 115 if ('If-None-Match' in self.headers and | |
| 116 etag == self.headers['If-None-Match']): | |
| 117 self.send_response(304) | |
| 118 return None | |
| 119 | |
| 120 # Handle If-Modified-Since | |
| 121 if ('If-None-Match' not in self.headers and | |
| 122 'If-Modified-Since' in self.headers): | |
| 123 last_modified = datetime.datetime.fromtimestamp( | |
| 124 math.floor(os.stat(path).st_mtime), tz=UTC) | |
| 125 ims = datetime.datetime( | |
| 126 *email.utils.parsedate(self.headers['If-Modified-Since'])[:6], | |
| 127 tzinfo=UTC) | |
| 128 if last_modified <= ims: | |
| 129 self.send_response(304) | |
| 130 return None | |
| 131 | |
| 132 return SimpleHTTPServer.SimpleHTTPRequestHandler.send_head(self) | |
| 133 | |
| 134 def end_headers(self): | |
| 135 path = self.translate_path(self.path) | |
| 136 | |
| 137 if os.path.isfile(path): | |
| 138 etag = self.get_etag() | |
| 139 if etag: | |
| 140 self.send_header('ETag', etag) | |
| 141 self.send_header('Cache-Control', 'must-revalidate') | |
| 142 | |
| 143 return SimpleHTTPServer.SimpleHTTPRequestHandler.end_headers(self) | |
| 144 | |
| 145 def translate_path(self, path): | |
| 146 path_from_current = ( | |
| 147 SimpleHTTPServer.SimpleHTTPRequestHandler.translate_path(self, path)) | |
| 148 return os.path.join(base_path, os.path.relpath(path_from_current)) | |
| 149 | |
| 150 def log_message(self, *_): | |
| 151 """ | |
| 152 Override the base class method to disable logging. | |
| 153 """ | |
| 154 pass | |
| 155 | |
| 156 RequestHandler.protocol_version = 'HTTP/1.1' | |
| 157 return RequestHandler | |
| 158 | |
| 159 | |
| 160 def _IsMapOrigin(arg): | |
| 161 """Returns whether arg is a --map-origin argument.""" | |
| 162 return arg.startswith(MAPPING_PREFIX) | |
| 163 | |
| 164 | |
| 165 def _Split(l, pred): | |
| 166 positive = [] | |
| 167 negative = [] | |
| 168 for v in l: | |
| 169 if pred(v): | |
| 170 positive.append(v) | |
| 171 else: | |
| 172 negative.append(v) | |
| 173 return (positive, negative) | |
| 174 | |
| 175 | |
| 176 def _ExitIfNeeded(process): | |
| 177 """ | |
| 178 Exits |process| if it is still alive. | |
| 179 """ | |
| 180 if process.poll() is None: | |
| 181 process.kill() | |
| 182 | |
| 183 | |
| 184 class AndroidShell(object): | |
| 185 """ Allows to set up and run a given mojo shell binary on an Android device. | |
| 186 | |
| 187 Args: | |
| 188 shell_apk_path: path to the shell Android binary | |
| 189 local_dir: directory where locally build Mojo apps will be served, optional | |
| 190 adb_path: path to adb, optional if adb is in PATH | |
| 191 """ | |
| 192 def __init__(self, shell_apk_path, local_dir=None, adb_path="adb"): | |
| 193 self.shell_apk_path = shell_apk_path | |
| 194 self.adb_path = adb_path | |
| 195 self.local_dir = local_dir | |
| 196 | |
| 197 def _ReadFifo(self, fifo_path, pipe, on_fifo_closed, max_attempts=5): | |
| 198 """ | |
| 199 Reads |fifo_path| on the device and write the contents to |pipe|. Calls | |
| 200 |on_fifo_closed| when the fifo is closed. This method will try to find the | |
| 201 path up to |max_attempts|, waiting 1 second between each attempt. If it | |
| 202 cannot find |fifo_path|, a exception will be raised. | |
| 203 """ | |
| 204 fifo_command = [self.adb_path, 'shell', 'test -e "%s"; echo $?' % fifo_path] | |
| 205 | |
| 206 def Run(): | |
| 207 def _WaitForFifo(): | |
| 208 for _ in xrange(max_attempts): | |
| 209 if subprocess.check_output(fifo_command)[0] == '0': | |
| 210 return | |
| 211 time.sleep(1) | |
| 212 if on_fifo_closed: | |
| 213 on_fifo_closed() | |
| 214 raise Exception("Unable to find fifo.") | |
| 215 _WaitForFifo() | |
| 216 stdout_cat = subprocess.Popen([self.adb_path, | |
| 217 'shell', | |
| 218 'cat', | |
| 219 fifo_path], | |
| 220 stdout=pipe) | |
| 221 atexit.register(_ExitIfNeeded, stdout_cat) | |
| 222 stdout_cat.wait() | |
| 223 if on_fifo_closed: | |
| 224 on_fifo_closed() | |
| 225 | |
| 226 thread = threading.Thread(target=Run, name="StdoutRedirector") | |
| 227 thread.start() | |
| 228 | |
| 229 def _MapPort(self, device_port, host_port): | |
| 230 """ | |
| 231 Maps the device port to the host port. If |device_port| is 0, a random | |
| 232 available port is chosen. Returns the device port. | |
| 233 """ | |
| 234 def _FindAvailablePortOnDevice(): | |
| 235 opened = subprocess.check_output([self.adb_path, 'shell', 'netstat']) | |
| 236 opened = [int(x.strip().split()[3].split(':')[1]) | |
| 237 for x in opened if x.startswith(' tcp')] | |
| 238 while True: | |
| 239 port = random.randint(4096, 16384) | |
| 240 if port not in opened: | |
| 241 return port | |
| 242 if device_port == 0: | |
| 243 device_port = _FindAvailablePortOnDevice() | |
| 244 subprocess.Popen([self.adb_path, | |
| 245 "reverse", | |
| 246 "tcp:%d" % device_port, | |
| 247 "tcp:%d" % host_port]).wait() | |
| 248 | |
| 249 unmap_command = [self.adb_path, "reverse", "--remove", | |
| 250 "tcp:%d" % device_port] | |
| 251 | |
| 252 def _UnmapPort(): | |
| 253 subprocess.Popen(unmap_command) | |
| 254 atexit.register(_UnmapPort) | |
| 255 return device_port | |
| 256 | |
| 257 def _StartHttpServerForDirectory(self, path, port=0): | |
| 258 """Starts an http server serving files from |path|. Returns the local | |
| 259 url.""" | |
| 260 assert path | |
| 261 print 'starting http for', path | |
| 262 httpd = _SilentTCPServer(('127.0.0.1', 0), _GetHandlerClassForPath(path)) | |
| 263 atexit.register(httpd.shutdown) | |
| 264 | |
| 265 http_thread = threading.Thread(target=httpd.serve_forever) | |
| 266 http_thread.daemon = True | |
| 267 http_thread.start() | |
| 268 | |
| 269 print 'local port=%d' % httpd.server_address[1] | |
| 270 return 'http://127.0.0.1:%d/' % self._MapPort(port, httpd.server_address[1]) | |
| 271 | |
| 272 def _StartHttpServerForOriginMapping(self, mapping, port): | |
| 273 """If |mapping| points at a local file starts an http server to serve files | |
| 274 from the directory and returns the new mapping. | |
| 275 | |
| 276 This is intended to be called for every --map-origin value.""" | |
| 277 parts = mapping.split('=') | |
| 278 if len(parts) != 2: | |
| 279 return mapping | |
| 280 dest = parts[1] | |
| 281 # If the destination is a url, don't map it. | |
| 282 if urlparse.urlparse(dest)[0]: | |
| 283 return mapping | |
| 284 # Assume the destination is a local file. Start a local server that | |
| 285 # redirects to it. | |
| 286 localUrl = self._StartHttpServerForDirectory(dest, port) | |
| 287 print 'started server at %s for %s' % (dest, localUrl) | |
| 288 return parts[0] + '=' + localUrl | |
| 289 | |
| 290 def _StartHttpServerForOriginMappings(self, map_parameters, fixed_port): | |
| 291 """Calls _StartHttpServerForOriginMapping for every --map-origin | |
| 292 argument.""" | |
| 293 if not map_parameters: | |
| 294 return [] | |
| 295 | |
| 296 original_values = list(itertools.chain( | |
| 297 *map(lambda x: x[len(MAPPING_PREFIX):].split(','), map_parameters))) | |
| 298 sorted(original_values) | |
| 299 result = [] | |
| 300 for i, value in enumerate(original_values): | |
| 301 result.append(self._StartHttpServerForOriginMapping( | |
| 302 value, DEFAULT_BASE_PORT + 1 + i if fixed_port else 0)) | |
| 303 return [MAPPING_PREFIX + ','.join(result)] | |
| 304 | |
| 305 def PrepareShellRun(self, origin=None, fixed_port=True): | |
| 306 """ Prepares for StartShell: runs adb as root and installs the apk. If no | |
| 307 --origin is specified, local http server will be set up to serve files from | |
| 308 the build directory along with port forwarding. | |
| 309 | |
| 310 Returns arguments that should be appended to shell argument list.""" | |
| 311 if 'cannot run as root' in subprocess.check_output([self.adb_path, 'root']): | |
| 312 raise Exception("Unable to run adb as root.") | |
| 313 subprocess.check_call( | |
| 314 [self.adb_path, 'install', '-r', self.shell_apk_path, '-i', | |
| 315 MOJO_SHELL_PACKAGE_NAME]) | |
| 316 atexit.register(self.StopShell) | |
| 317 | |
| 318 extra_shell_args = [] | |
| 319 origin_url = origin if origin else self._StartHttpServerForDirectory( | |
| 320 self.local_dir, DEFAULT_BASE_PORT if fixed_port else 0) | |
| 321 extra_shell_args.append("--origin=" + origin_url) | |
| 322 | |
| 323 return extra_shell_args | |
| 324 | |
| 325 def StartShell(self, | |
| 326 arguments, | |
| 327 stdout=None, | |
| 328 on_application_stop=None, | |
| 329 fixed_port=True): | |
| 330 """ | |
| 331 Starts the mojo shell, passing it the given arguments. | |
| 332 | |
| 333 The |arguments| list must contain the "--origin=" arg from PrepareShellRun. | |
| 334 If |stdout| is not None, it should be a valid argument for subprocess.Popen. | |
| 335 """ | |
| 336 STDOUT_PIPE = "/data/data/%s/stdout.fifo" % MOJO_SHELL_PACKAGE_NAME | |
| 337 | |
| 338 cmd = [self.adb_path, | |
| 339 'shell', | |
| 340 'am', | |
| 341 'start', | |
| 342 '-S', | |
| 343 '-a', 'android.intent.action.VIEW', | |
| 344 '-n', '%s/.MojoShellActivity' % MOJO_SHELL_PACKAGE_NAME] | |
| 345 | |
| 346 parameters = [] | |
| 347 if stdout or on_application_stop: | |
| 348 subprocess.check_call([self.adb_path, 'shell', 'rm', STDOUT_PIPE]) | |
| 349 parameters.append('--fifo-path=%s' % STDOUT_PIPE) | |
| 350 self._ReadFifo(STDOUT_PIPE, stdout, on_application_stop) | |
| 351 # The origin has to be specified whether it's local or external. | |
| 352 assert any("--origin=" in arg for arg in arguments) | |
| 353 | |
| 354 # Extract map-origin arguments. | |
| 355 map_parameters, other_parameters = _Split(arguments, _IsMapOrigin) | |
| 356 parameters += other_parameters | |
| 357 parameters += self._StartHttpServerForOriginMappings(map_parameters, | |
| 358 fixed_port) | |
| 359 | |
| 360 if parameters: | |
| 361 encodedParameters = json.dumps(parameters) | |
| 362 cmd += ['--es', 'encodedParameters', encodedParameters] | |
| 363 | |
| 364 with open(os.devnull, 'w') as devnull: | |
| 365 subprocess.Popen(cmd, stdout=devnull).wait() | |
| 366 | |
| 367 def StopShell(self): | |
| 368 """ | |
| 369 Stops the mojo shell. | |
| 370 """ | |
| 371 subprocess.check_call( | |
| 372 [self.adb_path, 'shell', 'am', 'force-stop', MOJO_SHELL_PACKAGE_NAME]) | |
| 373 | |
| 374 def CleanLogs(self): | |
| 375 """ | |
| 376 Cleans the logs on the device. | |
| 377 """ | |
| 378 subprocess.check_call([self.adb_path, 'logcat', '-c']) | |
| 379 | |
| 380 def ShowLogs(self): | |
| 381 """ | |
| 382 Displays the log for the mojo shell. | |
| 383 | |
| 384 Returns the process responsible for reading the logs. | |
| 385 """ | |
| 386 logcat = subprocess.Popen([self.adb_path, | |
| 387 'logcat', | |
| 388 '-s', | |
| 389 ' '.join(LOGCAT_TAGS)], | |
| 390 stdout=sys.stdout) | |
| 391 atexit.register(_ExitIfNeeded, logcat) | |
| 392 return logcat | |
| OLD | NEW |