// Copyright 2016 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 '../artifacts.dart'; import '../base/common.dart'; import '../base/file_system.dart'; import '../base/logger.dart'; import '../base/os.dart'; import '../base/platform.dart'; import '../base/process.dart'; import '../base/utils.dart'; import '../build_info.dart'; import '../cache.dart'; import '../globals.dart'; import '../plugins.dart'; import 'android_sdk.dart'; import 'android_studio.dart'; const String gradleManifestPath = 'android/app/src/main/AndroidManifest.xml'; const String gradleAppOutV1 = 'android/app/build/outputs/apk/app-debug.apk'; const String gradleAppOutDirV1 = 'android/app/build/outputs/apk'; const String gradleVersion = '3.3'; final RegExp _assembleTaskPattern = new RegExp(r'assemble([^:]+): task '); GradleProject _cachedGradleProject; String _cachedGradleExecutable; enum FlutterPluginVersion { none, v1, v2, managed, } bool isProjectUsingGradle() { return fs.isFileSync('android/build.gradle'); } FlutterPluginVersion get flutterPluginVersion { final File plugin = fs.file('android/buildSrc/src/main/groovy/FlutterPlugin.groovy'); if (plugin.existsSync()) { final String packageLine = plugin.readAsLinesSync().skip(4).first; if (packageLine == "package io.flutter.gradle") { return FlutterPluginVersion.v2; } return FlutterPluginVersion.v1; } final File appGradle = fs.file('android/app/build.gradle'); if (appGradle.existsSync()) { for (String line in appGradle.readAsLinesSync()) { if (line.contains(new RegExp(r"apply from: .*/flutter.gradle"))) { return FlutterPluginVersion.managed; } } } return FlutterPluginVersion.none; } /// Returns the path to the apk file created by [buildGradleProject], relative /// to current directory. Future<String> getGradleAppOut() async { switch (flutterPluginVersion) { case FlutterPluginVersion.none: // Fall through. Pretend we're v1, and just go with it. case FlutterPluginVersion.v1: return gradleAppOutV1; case FlutterPluginVersion.managed: // Fall through. The managed plugin matches plugin v2 for now. case FlutterPluginVersion.v2: return fs.path.relative(fs.path.join((await _gradleProject()).apkDirectory, 'app.apk')); } return null; } Future<GradleProject> _gradleProject() async { _cachedGradleProject ??= await _readGradleProject(); return _cachedGradleProject; } // Note: Dependencies are resolved and possibly downloaded as a side-effect // of calculating the app properties using Gradle. This may take minutes. Future<GradleProject> _readGradleProject() async { final String gradle = await _ensureGradle(); updateLocalProperties(); try { final Status status = logger.startProgress('Resolving dependencies...', expectSlowOperation: true); final RunResult runResult = await runCheckedAsync( <String>[gradle, 'app:properties'], workingDirectory: 'android', environment: _gradleEnv, ); final String properties = runResult.stdout.trim(); final GradleProject project = new GradleProject.fromAppProperties(properties); status.stop(); return project; } catch (e) { if (flutterPluginVersion == FlutterPluginVersion.managed) { // Handle known exceptions. This will exit if handled. handleKnownGradleExceptions(e); // Print a general Gradle error and exit. printError('* Error running Gradle:\n$e\n'); throwToolExit('Please review your Gradle project setup in the android/ folder.'); } } // Fall back to the default return new GradleProject(<String>['debug', 'profile', 'release'], <String>[], gradleAppOutDirV1); } void handleKnownGradleExceptions(String exceptionString) { // Handle Gradle error thrown when Gradle needs to download additional // Android SDK components (e.g. Platform Tools), and the license // for that component has not been accepted. final String matcher = r'You have not accepted the license agreements of the following SDK components:' r'\s*\[(.+)\]'; final RegExp licenseFailure = new RegExp(matcher, multiLine: true); final Match licenseMatch = licenseFailure.firstMatch(exceptionString); if (licenseMatch != null) { final String missingLicenses = licenseMatch.group(1); final String errorMessage = '\n\n* Error running Gradle:\n' 'Unable to download needed Android SDK components, as the following licenses have not been accepted:\n' '$missingLicenses\n\n' 'To resolve this, please run the following command in a Terminal:\n' 'flutter doctor --android-licenses'; throwToolExit(errorMessage); } } String _locateProjectGradlew({ bool ensureExecutable: true }) { final String path = fs.path.join( 'android', platform.isWindows ? 'gradlew.bat' : 'gradlew', ); if (fs.isFileSync(path)) { final File gradle = fs.file(path); if (ensureExecutable) os.makeExecutable(gradle); return gradle.absolute.path; } else { return null; } } Future<String> _ensureGradle() async { _cachedGradleExecutable ??= await _initializeGradle(); return _cachedGradleExecutable; } // Note: Gradle may be bootstrapped and possibly downloaded as a side-effect // of validating the Gradle executable. This may take several seconds. Future<String> _initializeGradle() async { final Status status = logger.startProgress('Initializing gradle...', expectSlowOperation: true); String gradle = _locateProjectGradlew(); if (gradle == null) { _injectGradleWrapper(); gradle = _locateProjectGradlew(); } if (gradle == null) throwToolExit('Unable to locate gradlew script'); printTrace('Using gradle from $gradle.'); // Validates the Gradle executable by asking for its version. // Makes Gradle Wrapper download and install Gradle distribution, if needed. await runCheckedAsync(<String>[gradle, '-v'], environment: _gradleEnv); status.stop(); return gradle; } void _injectGradleWrapper() { copyDirectorySync(cache.getArtifactDirectory('gradle_wrapper'), fs.directory('android')); final String propertiesPath = fs.path.join('android', 'gradle', 'wrapper', 'gradle-wrapper.properties'); if (!fs.file(propertiesPath).existsSync()) { fs.file(propertiesPath).writeAsStringSync(''' distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists distributionUrl=https\\://services.gradle.org/distributions/gradle-$gradleVersion-all.zip ''', flush: true, ); } } /// Create android/local.properties if needed, and update Flutter settings. void updateLocalProperties({String projectPath, BuildInfo buildInfo}) { final File localProperties = (projectPath == null) ? fs.file(fs.path.join('android', 'local.properties')) : fs.file(fs.path.join(projectPath, 'android', 'local.properties')); bool changed = false; SettingsFile settings; if (localProperties.existsSync()) { settings = new SettingsFile.parseFromFile(localProperties); } else { settings = new SettingsFile(); settings.values['sdk.dir'] = escapePath(androidSdk.directory); changed = true; } final String escapedRoot = escapePath(Cache.flutterRoot); if (changed || settings.values['flutter.sdk'] != escapedRoot) { settings.values['flutter.sdk'] = escapedRoot; changed = true; } if (buildInfo != null && settings.values['flutter.buildMode'] != buildInfo.modeName) { settings.values['flutter.buildMode'] = buildInfo.modeName; changed = true; } if (changed) settings.writeContents(localProperties); } Future<Null> buildGradleProject(BuildInfo buildInfo, String target) async { // Update the local.properties file with the build mode. // FlutterPlugin v1 reads local.properties to determine build mode. Plugin v2 // uses the standard Android way to determine what to build, but we still // update local.properties, in case we want to use it in the future. updateLocalProperties(buildInfo: buildInfo); injectPlugins(); final String gradle = await _ensureGradle(); switch (flutterPluginVersion) { case FlutterPluginVersion.none: // Fall through. Pretend it's v1, and just go for it. case FlutterPluginVersion.v1: return _buildGradleProjectV1(gradle); case FlutterPluginVersion.managed: // Fall through. Managed plugin builds the same way as plugin v2. case FlutterPluginVersion.v2: return _buildGradleProjectV2(gradle, buildInfo, target); } } Future<Null> _buildGradleProjectV1(String gradle) async { // Run 'gradlew build'. final Status status = logger.startProgress('Running \'gradlew build\'...', expectSlowOperation: true); final int exitCode = await runCommandAndStreamOutput( <String>[fs.file(gradle).absolute.path, 'build'], workingDirectory: 'android', allowReentrantFlutter: true, environment: _gradleEnv, ); status.stop(); if (exitCode != 0) throwToolExit('Gradle build failed: $exitCode', exitCode: exitCode); final File apkFile = fs.file(gradleAppOutV1); printStatus('Built $gradleAppOutV1 (${getSizeAsMB(apkFile.lengthSync())}).'); } Future<Null> _buildGradleProjectV2(String gradle, BuildInfo buildInfo, String target) async { final GradleProject project = await _gradleProject(); final String assembleTask = project.assembleTaskFor(buildInfo); if (assembleTask == null) { printError(''); printError('The Gradle project does not define a task suitable for the requested build.'); if (!project.buildTypes.contains(buildInfo.modeName)) { printError('Review the android/app/build.gradle file and ensure it defines a ${buildInfo.modeName} build type.'); } else { if (project.productFlavors.isEmpty) { printError('The android/app/build.gradle file does not define any custom product flavors.'); printError('You cannot use the --flavor option.'); } else { printError('The android/app/build.gradle file defines product flavors: ${project.productFlavors.join(', ')}'); printError('You must specify a --flavor option to select one of them.'); } throwToolExit('Gradle build aborted.'); } } final Status status = logger.startProgress('Running \'gradlew $assembleTask\'...', expectSlowOperation: true); final String gradlePath = fs.file(gradle).absolute.path; final List<String> command = <String>[gradlePath]; if (!logger.isVerbose) { command.add('-q'); } if (artifacts is LocalEngineArtifacts) { final LocalEngineArtifacts localEngineArtifacts = artifacts; printTrace('Using local engine: ${localEngineArtifacts.engineOutPath}'); command.add('-PlocalEngineOut=${localEngineArtifacts.engineOutPath}'); } if (target != null) { command.add('-Ptarget=$target'); } if (buildInfo.previewDart2) command.add('-Ppreview-dart-2=true'); command.add(assembleTask); final int exitCode = await runCommandAndStreamOutput( command, workingDirectory: 'android', allowReentrantFlutter: true, environment: _gradleEnv, ); status.stop(); if (exitCode != 0) throwToolExit('Gradle build failed: $exitCode', exitCode: exitCode); final File apkFile = _findApkFile(project, buildInfo); if (apkFile == null) throwToolExit('Gradle build failed to produce an Android package.'); // Copy the APK to app.apk, so `flutter run`, `flutter install`, etc. can find it. apkFile.copySync(fs.path.join(project.apkDirectory, 'app.apk')); printTrace('calculateSha: ${project.apkDirectory}/app.apk'); final File apkShaFile = fs.file(fs.path.join(project.apkDirectory, 'app.apk.sha1')); apkShaFile.writeAsStringSync(calculateSha(apkFile)); printStatus('Built ${fs.path.relative(apkFile.path)} (${getSizeAsMB(apkFile.lengthSync())}).'); } File _findApkFile(GradleProject project, BuildInfo buildInfo) { final String apkFileName = project.apkFileFor(buildInfo); if (apkFileName == null) return null; File apkFile = fs.file(fs.path.join(project.apkDirectory, apkFileName)); if (apkFile.existsSync()) return apkFile; apkFile = fs.file(fs.path.join(project.apkDirectory, buildInfo.modeName, apkFileName)); if (apkFile.existsSync()) return apkFile; return null; } Map<String, String> get _gradleEnv { final Map<String, String> env = new Map<String, String>.from(platform.environment); if (javaPath != null) { // Use java bundled with Android Studio. env['JAVA_HOME'] = javaPath; } return env; } class GradleProject { GradleProject(this.buildTypes, this.productFlavors, this.apkDirectory); factory GradleProject.fromAppProperties(String properties) { // Extract build directory. final String buildDir = properties .split('\n') .firstWhere((String s) => s.startsWith('buildDir: ')) .substring('buildDir: '.length) .trim(); // Extract build types and product flavors. final Set<String> variants = new Set<String>(); properties.split('\n').forEach((String s) { final Match match = _assembleTaskPattern.matchAsPrefix(s); if (match != null) { final String variant = match.group(1).toLowerCase(); if (!variant.endsWith('test')) variants.add(variant); } }); final Set<String> buildTypes = new Set<String>(); final Set<String> productFlavors = new Set<String>(); for (final String variant1 in variants) { for (final String variant2 in variants) { if (variant2.startsWith(variant1) && variant2 != variant1) { final String buildType = variant2.substring(variant1.length); if (variants.contains(buildType)) { buildTypes.add(buildType); productFlavors.add(variant1); } } } } if (productFlavors.isEmpty) buildTypes.addAll(variants); return new GradleProject( buildTypes.toList(), productFlavors.toList(), fs.path.normalize(fs.path.join(buildDir, 'outputs', 'apk')), ); } final List<String> buildTypes; final List<String> productFlavors; final String apkDirectory; String _buildTypeFor(BuildInfo buildInfo) { if (buildTypes.contains(buildInfo.modeName)) return buildInfo.modeName; return null; } String _productFlavorFor(BuildInfo buildInfo) { if (buildInfo.flavor == null) return productFlavors.isEmpty ? '' : null; else if (productFlavors.contains(buildInfo.flavor.toLowerCase())) return buildInfo.flavor.toLowerCase(); else return null; } String assembleTaskFor(BuildInfo buildInfo) { final String buildType = _buildTypeFor(buildInfo); final String productFlavor = _productFlavorFor(buildInfo); if (buildType == null || productFlavor == null) return null; return 'assemble${toTitleCase(productFlavor)}${toTitleCase(buildType)}'; } String apkFileFor(BuildInfo buildInfo) { final String buildType = _buildTypeFor(buildInfo); final String productFlavor = _productFlavorFor(buildInfo); if (buildType == null || productFlavor == null) return null; final String flavorString = productFlavor.isEmpty ? '' : '-' + productFlavor; return 'app$flavorString-$buildType.apk'; } }