| #!/usr/bin/env python |
| # Copyright 2015 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. |
| |
| import argparse |
| import atexit |
| import json |
| import logging |
| import os |
| import random |
| import re |
| import signal |
| import socket |
| import subprocess |
| import sys |
| import time |
| import urlparse |
| |
| # TODO(eseidel): This should be BIN_DIR. |
| PACKAGES_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| SKY_ENGINE_DIR = os.path.join(PACKAGES_DIR, 'sky_engine') |
| APK_DIR = os.path.join(os.path.realpath(SKY_ENGINE_DIR), os.pardir, 'apks') |
| |
| SKY_SERVER_PORT = 9888 |
| OBSERVATORY_PORT = 8181 |
| ADB_PATH = 'adb' |
| APK_NAME = 'SkyShell.apk' |
| ANDROID_PACKAGE = "org.domokit.sky.shell" |
| ANDROID_COMPONENT = '%s/%s.SkyActivity' % (ANDROID_PACKAGE, ANDROID_PACKAGE) |
| |
| # FIXME: Do we need to look in $DART_SDK? |
| DART_PATH = 'dart' |
| PUB_PATH = 'pub' |
| |
| PID_FILE_PATH = "/tmp/sky_tool.pids" |
| PID_FILE_KEYS = frozenset([ |
| 'remote_sky_server_port', |
| 'sky_server_pid', |
| 'sky_server_port', |
| 'sky_server_root', |
| ]) |
| |
| |
| def _port_in_use(port): |
| sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) |
| return sock.connect_ex(('localhost', port)) == 0 |
| |
| |
| def _start_http_server(port, root): |
| server_command = [ |
| PUB_PATH, 'run', 'sky_tools:sky_server', str(port), |
| ] |
| return subprocess.Popen(server_command, cwd=root).pid |
| |
| |
| # This 'strict dictionary' approach is useful for catching typos. |
| class Pids(object): |
| def __init__(self, known_keys, contents=None): |
| self._known_keys = known_keys |
| self._dict = contents if contents is not None else {} |
| |
| def __len__(self): |
| return len(self._dict) |
| |
| def get(self, key, default=None): |
| assert key in self._known_keys, '%s not in known_keys' % key |
| return self._dict.get(key, default) |
| |
| def __getitem__(self, key): |
| assert key in self._known_keys, '%s not in known_keys' % key |
| return self._dict[key] |
| |
| def __setitem__(self, key, value): |
| assert key in self._known_keys, '%s not in known_keys' % key |
| self._dict[key] = value |
| |
| def __delitem__(self, key): |
| assert key in self._known_keys, '%s not in known_keys' % key |
| del self._dict[key] |
| |
| def __iter__(self): |
| return iter(self._dict) |
| |
| def __contains__(self, key): |
| assert key in self._known_keys, '%s not in allowed_keys' % key |
| return key in self._dict |
| |
| def clear(self): |
| self._dict = {} |
| |
| def pop(self, key, default=None): |
| assert key in self._known_keys, '%s not in known_keys' % key |
| return self._dict.pop(key, default) |
| |
| @classmethod |
| def read_from(cls, path, known_keys): |
| contents = {} |
| try: |
| with open(path, 'r') as pid_file: |
| contents = json.load(pid_file) |
| except: |
| if os.path.exists(path): |
| logging.warn('Failed to read pid file: %s' % path) |
| return cls(known_keys, contents) |
| |
| def write_to(self, path): |
| # These keys are required to write a valid file. |
| if not self._dict.viewkeys() >= { 'sky_server_pid', 'sky_server_port' }: |
| return |
| |
| try: |
| with open(path, 'w') as pid_file: |
| json.dump(self._dict, pid_file, indent=2, sort_keys=True) |
| except: |
| logging.warn('Failed to write pid file: %s' % path) |
| |
| |
| def _url_for_path(port, root, path): |
| relative_path = os.path.relpath(path, root) |
| return 'http://localhost:%s/%s' % (port, relative_path) |
| |
| |
| class StartSky(object): |
| def add_subparser(self, subparsers): |
| start_parser = subparsers.add_parser('start', |
| help='launch %s on the device' % APK_NAME) |
| start_parser.add_argument('--install', action='store_true') |
| start_parser.add_argument('--poke', action='store_true') |
| start_parser.add_argument('--checked', action='store_true') |
| start_parser.add_argument('--build-path', type=str) |
| start_parser.add_argument('project_or_path', nargs='?', type=str, |
| default='.') |
| start_parser.set_defaults(func=self.run) |
| |
| def _is_package_installed(self, package_name): |
| pm_path_cmd = [ADB_PATH, 'shell', 'pm', 'path', package_name] |
| return subprocess.check_output(pm_path_cmd).strip() != '' |
| |
| def _is_valid_script_path(self): |
| script_path = os.path.dirname(os.path.abspath(__file__)) |
| script_dirs = script_path.split('/') |
| return len(script_dirs) > 1 and script_dirs[-2] == 'packages' |
| |
| def run(self, args, pids): |
| if not args.poke: |
| StopSky().run(args, pids) |
| |
| project_or_path = os.path.abspath(args.project_or_path) |
| |
| if os.path.isdir(project_or_path): |
| sky_server_root = project_or_path |
| main_dart = os.path.join(project_or_path, 'lib', 'main.dart') |
| missing_msg = "Missing lib/main.dart in project: %s" % project_or_path |
| else: |
| # FIXME: This assumes the path is at the root of the project! |
| # Instead we should walk up looking for a pubspec.yaml |
| sky_server_root = os.path.dirname(project_or_path) |
| main_dart = project_or_path |
| missing_msg = "%s does not exist." % main_dart |
| |
| if not os.path.isfile(main_dart): |
| logging.error(missing_msg) |
| return 2 |
| |
| package_root = os.path.join(sky_server_root, 'packages') |
| if not os.path.isdir(package_root): |
| logging.error("%s is not a valid packages path." % package_root) |
| return 2 |
| |
| if not self._is_package_installed(ANDROID_PACKAGE): |
| logging.info('%s is not on the device. Installing now...' % APK_NAME) |
| args.install = True |
| |
| if args.install: |
| if not self._is_valid_script_path(): |
| logging.error("'%s' must be located in packages/sky. " \ |
| "The directory packages/sky_engine must also " \ |
| "exist to locate %s." \ |
| % (os.path.basename(__file__), APK_NAME)) |
| return 2 |
| if args.build_path is not None: |
| apk_path = os.path.join(args.build_path, 'apks', APK_NAME) |
| else: |
| apk_path = os.path.join(APK_DIR, APK_NAME) |
| if not os.path.exists(apk_path): |
| logging.error("'%s' does not exist?" % apk_path) |
| return 2 |
| |
| subprocess.check_call([ADB_PATH, 'install', '-r', apk_path]) |
| |
| # Set up port forwarding for observatory |
| observatory_port_string = 'tcp:%s' % OBSERVATORY_PORT |
| subprocess.check_call([ |
| ADB_PATH, 'forward', observatory_port_string, observatory_port_string |
| ]) |
| |
| sky_server_port = SKY_SERVER_PORT |
| pids['sky_server_port'] = sky_server_port |
| if _port_in_use(sky_server_port): |
| logging.info(('Port %s already in use. ' |
| ' Not starting server for %s') % (sky_server_port, sky_server_root)) |
| else: |
| sky_server_pid = _start_http_server(sky_server_port, sky_server_root) |
| pids['sky_server_pid'] = sky_server_pid |
| pids['sky_server_root'] = sky_server_root |
| |
| port_string = 'tcp:%s' % sky_server_port |
| subprocess.check_call([ |
| ADB_PATH, 'reverse', port_string, port_string |
| ]) |
| pids['remote_sky_server_port'] = sky_server_port |
| |
| # The load happens on the remote device, use the remote port. |
| url = _url_for_path(pids['remote_sky_server_port'], sky_server_root, |
| main_dart) |
| if args.poke: |
| url += '?rand=%s' % random.random() |
| |
| cmd = [ |
| ADB_PATH, 'shell', |
| 'am', 'start', |
| '-a', 'android.intent.action.VIEW', |
| '-d', url, |
| ] |
| |
| if args.checked: |
| cmd += [ '--ez', 'enable-checked-mode', 'true' ] |
| |
| cmd += [ ANDROID_COMPONENT ] |
| |
| subprocess.check_output(cmd) |
| |
| |
| class StopSky(object): |
| def add_subparser(self, subparsers): |
| stop_parser = subparsers.add_parser('stop', |
| help=('kill all running SkyShell.apk processes')) |
| stop_parser.set_defaults(func=self.run) |
| |
| def _run(self, args): |
| with open('/dev/null', 'w') as dev_null: |
| subprocess.call(args, stdout=dev_null, stderr=dev_null) |
| |
| def run(self, args, pids): |
| self._run(['fuser', '-k', '%s/tcp' % SKY_SERVER_PORT]) |
| |
| if 'remote_sky_server_port' in pids: |
| port_string = 'tcp:%s' % pids['remote_sky_server_port'] |
| self._run([ADB_PATH, 'reverse', '--remove', port_string]) |
| |
| self._run([ADB_PATH, 'shell', 'am', 'force-stop', ANDROID_PACKAGE]) |
| |
| pids.clear() |
| |
| |
| class StartTracing(object): |
| def add_subparser(self, subparsers): |
| start_tracing_parser = subparsers.add_parser('start_tracing', |
| help=('start tracing a running sky instance')) |
| start_tracing_parser.set_defaults(func=self.run) |
| |
| def run(self, args, pids): |
| subprocess.check_output([ADB_PATH, 'shell', |
| 'am', 'broadcast', |
| '-a', 'org.domokit.sky.shell.TRACING_START']) |
| |
| |
| TRACE_COMPLETE_REGEXP = re.compile('Trace complete') |
| TRACE_FILE_REGEXP = re.compile(r'Saving trace to (?P<path>\S+)') |
| |
| |
| class StopTracing(object): |
| def add_subparser(self, subparsers): |
| stop_tracing_parser = subparsers.add_parser('stop_tracing', |
| help=('stop tracing a running sky instance')) |
| stop_tracing_parser.set_defaults(func=self.run) |
| |
| def run(self, args, pids): |
| subprocess.check_output([ADB_PATH, 'logcat', '-c']) |
| subprocess.check_output([ADB_PATH, 'shell', |
| 'am', 'broadcast', |
| '-a', 'org.domokit.sky.shell.TRACING_STOP']) |
| device_path = None |
| is_complete = False |
| while not is_complete: |
| time.sleep(0.2) |
| log = subprocess.check_output([ADB_PATH, 'logcat', '-d']) |
| if device_path is None: |
| result = TRACE_FILE_REGEXP.search(log) |
| if result: |
| device_path = result.group('path') |
| is_complete = TRACE_COMPLETE_REGEXP.search(log) is not None |
| |
| logging.info('Downloading trace %s ...' % os.path.basename(device_path)) |
| |
| if device_path: |
| subprocess.check_output([ADB_PATH, 'pull', device_path]) |
| subprocess.check_output([ADB_PATH, 'shell', 'rm', device_path]) |
| |
| |
| class SkyShellRunner(object): |
| def _update_paths(self): |
| global ADB_PATH |
| if 'ANDROID_HOME' in os.environ: |
| android_home_dir = os.environ['ANDROID_HOME'] |
| ADB_PATH = os.path.join(android_home_dir, 'sdk/platform-tools/adb') |
| |
| def _is_valid_adb_version(self, adb_version): |
| # Sample output: "Android Debug Bridge version 1.0.31" |
| version_fields = re.search('(\d+)\.(\d+)\.(\d+)', adb_version) |
| if version_fields: |
| major_version = int(version_fields.group(1)) |
| minor_version = int(version_fields.group(2)) |
| patch_version = int(version_fields.group(3)) |
| if major_version > 1: |
| return True |
| if major_version == 1 and minor_version > 0: |
| return True |
| if major_version == 1 and minor_version == 0 and patch_version >= 32: |
| return True |
| return False |
| else: |
| logging.warn('Unrecognized adb version string. Skipping version check.') |
| return True |
| |
| def _check_for_adb(self): |
| try: |
| adb_version = subprocess.check_output([ADB_PATH, 'version']) |
| if self._is_valid_adb_version(adb_version): |
| return True |
| |
| adb_path = subprocess.check_output( ['which', ADB_PATH]).rstrip() |
| logging.error("'%s' is too old. Need 1.0.32 or later. " \ |
| "Try setting ANDROID_HOME." % adb_path) |
| return False |
| |
| except OSError: |
| logging.error("'adb' (from the Android SDK) not in $PATH, can't continue.") |
| return False |
| return True |
| |
| def _check_for_lollipop_or_later(self): |
| try: |
| # If the server is automatically restarted, then we get irrelevant |
| # output lines like this, which we want to ignore: |
| # adb server is out of date. killing.. |
| # * daemon started successfully * |
| |
| subprocess.call([ADB_PATH, 'start-server']) |
| sdk_version = subprocess.check_output( |
| [ADB_PATH, 'shell', 'getprop', 'ro.build.version.sdk']).rstrip() |
| # Sample output: "22" |
| if not sdk_version.isdigit(): |
| logging.error("Unexpected response from getprop: '%s'." % sdk_version) |
| return False |
| |
| if int(sdk_version) < 22: |
| logging.error("Version '%s' of the Android SDK is too old. " \ |
| "Need Lollipop (22) or later. " % sdk_version) |
| return False |
| |
| except subprocess.CalledProcessError as e: |
| # adb printed the error, so we print nothing. |
| return False |
| return True |
| |
| def _check_for_dart(self): |
| try: |
| subprocess.check_output([DART_PATH, '--version'], stderr=subprocess.STDOUT) |
| except OSError: |
| logging.error("'dart' (from the Dart SDK) not in $PATH, can't continue.") |
| return False |
| return True |
| |
| def main(self): |
| logging.basicConfig(format='%(levelname)s:%(message)s', level=logging.INFO) |
| |
| self._update_paths() |
| if not self._check_for_adb() or not self._check_for_lollipop_or_later(): |
| sys.exit(2) |
| if not self._check_for_dart(): |
| sys.exit(2) |
| |
| parser = argparse.ArgumentParser(description='Sky Demo Runner') |
| subparsers = parser.add_subparsers(help='sub-command help') |
| |
| for command in [StartSky(), StopSky(), StartTracing(), StopTracing()]: |
| command.add_subparser(subparsers) |
| |
| args = parser.parse_args() |
| pids = Pids.read_from(PID_FILE_PATH, PID_FILE_KEYS) |
| atexit.register(pids.write_to, PID_FILE_PATH) |
| exit_code = 0 |
| try: |
| exit_code = args.func(args, pids) |
| except subprocess.CalledProcessError as e: |
| # Don't print a stack trace if the adb command fails. |
| logging.error(e) |
| exit_code = 2 |
| sys.exit(exit_code) |
| |
| |
| if __name__ == '__main__': |
| SkyShellRunner().main() |