// 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 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:path/path.dart' as path; import 'package:yaml/yaml.dart'; import '../android/device_android.dart'; import '../artifacts.dart'; import '../base/file_system.dart'; import '../base/logging.dart'; import '../base/process.dart'; import '../build_configuration.dart'; import '../flx.dart' as flx; import '../runner/flutter_command.dart'; import 'start.dart'; const String _kDefaultAndroidManifestPath = 'apk/AndroidManifest.xml'; const String _kDefaultOutputPath = 'build/app.apk'; const String _kDefaultResourcesPath = 'apk/res'; const String _kFlutterManifestPath = 'flutter.yaml'; const String _kPubspecYamlPath = 'pubspec.yaml'; // Alias of the key provided in the Chromium debug keystore const String _kDebugKeystoreKeyAlias = "chromiumdebugkey"; // Password for the Chromium debug keystore const String _kDebugKeystorePassword = "chromium"; const String _kAndroidPlatformVersion = '22'; const String _kBuildToolsVersion = '22.0.1'; /// Copies files into a new directory structure. class _AssetBuilder { final Directory outDir; Directory _assetDir; _AssetBuilder(this.outDir, String assetDirName) { _assetDir = new Directory('${outDir.path}/$assetDirName'); _assetDir.createSync(recursive: true); } void add(File asset, String relativePath) { String destPath = path.join(_assetDir.path, relativePath); ensureDirectoryExists(destPath); asset.copySync(destPath); } Directory get directory => _assetDir; } /// Builds an APK package using Android SDK tools. class _ApkBuilder { final String androidSdk; File _androidJar; File _aapt; File _dx; File _zipalign; String _jarsigner; _ApkBuilder(this.androidSdk) { _androidJar = new File('$androidSdk/platforms/android-$_kAndroidPlatformVersion/android.jar'); String buildTools = '$androidSdk/build-tools/$_kBuildToolsVersion'; _aapt = new File('$buildTools/aapt'); _dx = new File('$buildTools/dx'); _zipalign = new File('$buildTools/zipalign'); _jarsigner = 'jarsigner'; } bool checkSdkPath() { return (_androidJar.existsSync() && _aapt.existsSync() && _dx.existsSync() && _zipalign.existsSync()); } void compileClassesDex(File classesDex, List<File> jars) { List<String> packageArgs = [_dx.path, '--dex', '--force-jumbo', '--output', classesDex.path ]; packageArgs.addAll(jars.map((File f) => f.path)); runCheckedSync(packageArgs); } void package(File outputApk, File androidManifest, Directory assets, Directory artifacts, Directory resources) { List<String> packageArgs = [_aapt.path, 'package', '-M', androidManifest.path, '-A', assets.path, '-I', _androidJar.path, '-F', outputApk.path, ]; if (resources != null) { packageArgs.addAll(['-S', resources.absolute.path]); } packageArgs.add(artifacts.path); runCheckedSync(packageArgs); } void sign(File keystore, String keystorePassword, String keyAlias, String keyPassword, File outputApk) { runCheckedSync([_jarsigner, '-keystore', keystore.path, '-storepass', keystorePassword, '-keypass', keyPassword, outputApk.path, keyAlias, ]); } void align(File unalignedApk, File outputApk) { runCheckedSync([_zipalign.path, '-f', '4', unalignedApk.path, outputApk.path]); } } class _ApkComponents { Directory androidSdk; File manifest; File icuData; List<File> jars; List<Map<String, String>> services = []; File libSkyShell; File debugKeystore; Directory resources; } // TODO(mpcomplete): find a better home for this. dynamic _loadYamlFile(String path) { if (!FileSystemEntity.isFileSync(path)) return null; String manifestString = new File(path).readAsStringSync(); return loadYaml(manifestString); } class ApkCommand extends FlutterCommand { final String name = 'apk'; final String description = 'Build an Android APK package.'; ApkCommand() { argParser.addOption('manifest', abbr: 'm', defaultsTo: _kDefaultAndroidManifestPath, help: 'Android manifest XML file.'); argParser.addOption('resources', abbr: 'r', defaultsTo: _kDefaultResourcesPath, help: 'Resources directory path.'); argParser.addOption('output-file', abbr: 'o', defaultsTo: _kDefaultOutputPath, help: 'Output APK file.'); argParser.addOption('target', abbr: 't', defaultsTo: '', help: 'Target app path or filename used to build the FLX.'); argParser.addOption('flx', abbr: 'f', defaultsTo: '', help: 'Path to the FLX file. If this is not provided, an FLX will be built.'); argParser.addOption('keystore', defaultsTo: '', help: 'Path to the keystore used to sign the app.'); argParser.addOption('keystore-password', defaultsTo: '', help: 'Password used to access the keystore.'); argParser.addOption('keystore-key-alias', defaultsTo: '', help: 'Alias of the entry within the keystore.'); argParser.addOption('keystore-key-password', defaultsTo: '', help: 'Password for the entry within the keystore.'); } Future _findServices(_ApkComponents components) async { if (!ArtifactStore.isPackageRootValid) return; dynamic manifest = _loadYamlFile(_kFlutterManifestPath); if (manifest['services'] == null) return; for (String service in manifest['services']) { String serviceRoot = '${ArtifactStore.packageRoot}/$service/apk'; dynamic serviceConfig = _loadYamlFile('$serviceRoot/config.yaml'); if (serviceConfig == null || serviceConfig['jars'] == null) continue; components.services.addAll(serviceConfig['services']); for (String jar in serviceConfig['jars']) { if (jar.startsWith("android-sdk:")) { // Jar is something shipped in the standard android SDK. jar = jar.replaceAll('android-sdk:', '${components.androidSdk.path}/'); components.jars.add(new File(jar)); } else if (jar.startsWith("http")) { // Jar is a URL to download. String cachePath = await ArtifactStore.getThirdPartyFile(jar, service); components.jars.add(new File(cachePath)); } else { // Assume jar is a path relative to the service's root dir. components.jars.add(new File(path.join(serviceRoot, jar))); } } } } Future<_ApkComponents> _findApkComponents(BuildConfiguration config) async { String androidSdkPath; List<String> artifactPaths; if (runner.enginePath != null) { androidSdkPath = '${runner.enginePath}/third_party/android_tools/sdk'; artifactPaths = [ '${runner.enginePath}/third_party/icu/android/icudtl.dat', '${config.buildDir}/gen/sky/shell/shell/classes.dex.jar', '${config.buildDir}/gen/sky/shell/shell/shell/libs/armeabi-v7a/libsky_shell.so', '${runner.enginePath}/build/android/ant/chromium-debug.keystore', ]; } else { androidSdkPath = AndroidDevice.getAndroidSdkPath(); if (androidSdkPath == null) { return null; } List<ArtifactType> artifactTypes = <ArtifactType>[ ArtifactType.androidIcuData, ArtifactType.androidClassesJar, ArtifactType.androidLibSkyShell, ArtifactType.androidKeystore, ]; Iterable<Future<String>> pathFutures = artifactTypes.map( (ArtifactType type) => ArtifactStore.getPath(ArtifactStore.getArtifact( type: type, targetPlatform: TargetPlatform.android))); artifactPaths = await Future.wait(pathFutures); } _ApkComponents components = new _ApkComponents(); components.androidSdk = new Directory(androidSdkPath); components.manifest = new File(argResults['manifest']); components.icuData = new File(artifactPaths[0]); components.jars = [new File(artifactPaths[1])]; components.libSkyShell = new File(artifactPaths[2]); components.debugKeystore = new File(artifactPaths[3]); components.resources = new Directory(argResults['resources']); await _findServices(components); if (!components.resources.existsSync()) { // TODO(eseidel): This level should be higher when path is manually set. logging.info('Can not locate Resources: ${components.resources}, ignoring.'); components.resources = null; } if (!components.androidSdk.existsSync()) { logging.severe('Can not locate Android SDK: $androidSdkPath'); return null; } if (!(new _ApkBuilder(components.androidSdk.path).checkSdkPath())) { logging.severe('Can not locate expected Android SDK tools at $androidSdkPath'); logging.severe('You must install version $_kAndroidPlatformVersion of the SDK platform'); logging.severe('and version $_kBuildToolsVersion of the build tools.'); return null; } for (File f in [components.manifest, components.icuData, components.libSkyShell, components.debugKeystore] ..addAll(components.jars)) { if (!f.existsSync()) { logging.severe('Can not locate file: ${f.path}'); return null; } } return components; } // Outputs a services.json file for the flutter engine to read. Format: // { // services: [ // { name: string, class: string }, // ... // ] // } void _generateServicesConfig(File servicesConfig, List<Map<String, String>> servicesIn) { List<Map<String, String>> services = servicesIn.map((Map<String, String> service) => { 'name': service['name'], 'class': service['registration-class'] }).toList(); Map<String, dynamic> json = { 'services': services }; servicesConfig.writeAsStringSync(JSON.encode(json), mode: FileMode.WRITE, flush: true); } int _buildApk(_ApkComponents components, String flxPath) { Directory tempDir = Directory.systemTemp.createTempSync('flutter_tools'); try { _ApkBuilder builder = new _ApkBuilder(components.androidSdk.path); File classesDex = new File('${tempDir.path}/classes.dex'); builder.compileClassesDex(classesDex, components.jars); File servicesConfig = new File('${tempDir.path}/services.json'); _generateServicesConfig(servicesConfig, components.services); _AssetBuilder assetBuilder = new _AssetBuilder(tempDir, 'assets'); assetBuilder.add(components.icuData, 'icudtl.dat'); assetBuilder.add(new File(flxPath), 'app.flx'); assetBuilder.add(servicesConfig, 'services.json'); _AssetBuilder artifactBuilder = new _AssetBuilder(tempDir, 'artifacts'); artifactBuilder.add(classesDex, 'classes.dex'); artifactBuilder.add(components.libSkyShell, 'lib/armeabi-v7a/libsky_shell.so'); File unalignedApk = new File('${tempDir.path}/app.apk.unaligned'); builder.package(unalignedApk, components.manifest, assetBuilder.directory, artifactBuilder.directory, components.resources); int signResult = _signApk(builder, components, unalignedApk); if (signResult != 0) return signResult; File finalApk = new File(argResults['output-file']); ensureDirectoryExists(finalApk.path); builder.align(unalignedApk, finalApk); print('APK generated: ${finalApk.path}'); return 0; } finally { tempDir.deleteSync(recursive: true); } } int _signApk(_ApkBuilder builder, _ApkComponents components, File apk) { File keystore; String keystorePassword; String keyAlias; String keyPassword; if (argResults['keystore'].isEmpty) { logging.warning('Signing the APK using the debug keystore'); keystore = components.debugKeystore; keystorePassword = _kDebugKeystorePassword; keyAlias = _kDebugKeystoreKeyAlias; keyPassword = _kDebugKeystorePassword; } else { keystore = new File(argResults['keystore']); keystorePassword = argResults['keystore-password']; keyAlias = argResults['keystore-key-alias']; if (keystorePassword.isEmpty || keyAlias.isEmpty) { logging.severe('Must provide a keystore password and a key alias'); return 1; } keyPassword = argResults['keystore-key-password']; if (keyPassword.isEmpty) keyPassword = keystorePassword; } builder.sign(keystore, keystorePassword, keyAlias, keyPassword, apk); return 0; } @override Future<int> runInProject() async { BuildConfiguration config = buildConfigurations.firstWhere( (BuildConfiguration bc) => bc.targetPlatform == TargetPlatform.android ); _ApkComponents components = await _findApkComponents(config); if (components == null) { logging.severe('Unable to build APK.'); return 1; } String flxPath = argResults['flx']; if (!flxPath.isEmpty) { if (!FileSystemEntity.isFileSync(flxPath)) { logging.severe('FLX does not exist: $flxPath'); logging.severe('(Omit the --flx option to build the FLX automatically)'); return 1; } return _buildApk(components, flxPath); } else { await downloadToolchain(); // Find the path to the main Dart file. String mainPath = findMainDartFile(argResults['target']); // Build the FLX. flx.DirectoryResult buildResult = await flx.buildInTempDir(toolchain, mainPath: mainPath); try { return _buildApk(components, buildResult.localBundlePath); } finally { buildResult.dispose(); } } } }