// Copyright 2014 The Flutter 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 'package:meta/meta.dart'; import '../base/error_handling_io.dart'; import '../base/file_system.dart'; import '../base/process.dart'; import '../base/terminal.dart'; import '../globals.dart' as globals; import '../project.dart'; import '../reporting/reporting.dart'; import 'gradle_utils.dart'; import 'multidex.dart'; typedef GradleErrorTest = bool Function(String); /// A Gradle error handled by the tool. class GradleHandledError { const GradleHandledError({ required this.test, required this.handler, this.eventLabel, }); /// The test function. /// Returns [true] if the current error message should be handled. final GradleErrorTest test; /// The handler function. final Future<GradleBuildStatus> Function({ required String line, required FlutterProject project, required bool usesAndroidX, required bool multidexEnabled, }) handler; /// The [BuildEvent] label is named gradle-[eventLabel]. /// If not empty, the build event is logged along with /// additional metadata such as the attempt number. final String? eventLabel; } /// The status of the Gradle build. enum GradleBuildStatus { /// The tool cannot recover from the failure and should exit. exit, /// The tool can retry the exact same build. retry, } /// Returns a simple test function that evaluates to `true` if at least one of /// `errorMessages` is contained in the error message. GradleErrorTest _lineMatcher(List<String> errorMessages) { return (String line) { return errorMessages.any((String errorMessage) => line.contains(errorMessage)); }; } /// The list of Gradle errors that the tool can handle. /// /// The handlers are executed in the order in which they appear in the list. /// /// Only the first error handler for which the [test] function returns [true] /// is handled. As a result, sort error handlers based on how strict the [test] /// function is to eliminate false positives. final List<GradleHandledError> gradleErrors = <GradleHandledError>[ licenseNotAcceptedHandler, networkErrorHandler, permissionDeniedErrorHandler, flavorUndefinedHandler, r8FailureHandler, minSdkVersionHandler, transformInputIssueHandler, lockFileDepMissingHandler, multidexErrorHandler, incompatibleKotlinVersionHandler, minCompileSdkVersionHandler, jvm11RequiredHandler, outdatedGradleHandler, sslExceptionHandler, zipExceptionHandler, incompatibleJavaAndGradleVersionsHandler, remoteTerminatedHandshakeHandler, couldNotOpenCacheDirectoryHandler, ]; const String _boxTitle = 'Flutter Fix'; // Multidex error message. @visibleForTesting final GradleHandledError multidexErrorHandler = GradleHandledError( test: _lineMatcher(const <String>[ 'com.android.builder.dexing.DexArchiveMergerException: Error while merging dex archives:', 'The number of method references in a .dex file cannot exceed 64K.', ]), handler: ({ required String line, required FlutterProject project, required bool usesAndroidX, required bool multidexEnabled, }) async { globals.printStatus('${globals.logger.terminal.warningMark} App requires Multidex support', emphasis: true); if (multidexEnabled) { globals.printStatus( 'Multidex support is required for your android app to build since the number of methods has exceeded 64k. ' 'See https://docs.flutter.dev/deployment/android#enabling-multidex-support for more information. ' "You may pass the --no-multidex flag to skip Flutter's multidex support to use a manual solution.\n", indent: 4, ); if (!androidManifestHasNameVariable(project.directory)) { globals.printStatus( r'Your `android/app/src/main/AndroidManifest.xml` does not contain `android:name="${applicationName}"` ' 'under the `application` element. This may be due to creating your project with an old version of Flutter. ' 'Add the `android:name="\${applicationName}"` attribute to your AndroidManifest.xml to enable Flutter\'s multidex support:\n', indent: 4, ); globals.printStatus(r''' <manifest xmlns:android="http://schemas.android.com/apk/res/android" ... <application ... android:name=''', indent: 8, newline: false, color: TerminalColor.grey, ); globals.printStatus(r'"${applicationName}"', color: TerminalColor.green, newline: true); globals.printStatus(r''' ...> ''', indent: 8, color: TerminalColor.grey, ); globals.printStatus( 'You may also roll your own multidex support by following the guide at: https://developer.android.com/studio/build/multidex\n', indent: 4, ); return GradleBuildStatus.exit; } if (!multiDexApplicationExists(project.directory)) { globals.printStatus( 'Flutter tool can add multidex support. The following file will be added by flutter:\n', indent: 4, ); globals.printStatus( 'android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java\n', indent: 8, ); String selection = 'n'; // Default to 'no' if no interactive terminal. try { selection = await globals.terminal.promptForCharInput( <String>['y', 'n'], logger: globals.logger, prompt: 'Do you want to continue with adding multidex support for Android?', defaultChoiceIndex: 0, ); } on StateError catch (e) { globals.printError( e.message, indent: 0, ); } if (selection == 'y') { ensureMultiDexApplicationExists(project.directory); globals.printStatus( 'Multidex enabled. Retrying build.\n', indent: 0, ); return GradleBuildStatus.retry; } } } else { globals.printBox( 'Flutter multidex handling is disabled. If you wish to let the tool configure multidex, use the --multidex flag.', title: _boxTitle, ); } return GradleBuildStatus.exit; }, eventLabel: 'multidex-error', ); // Permission defined error message. @visibleForTesting final GradleHandledError permissionDeniedErrorHandler = GradleHandledError( test: _lineMatcher(const <String>[ 'Permission denied', ]), handler: ({ required String line, required FlutterProject project, required bool usesAndroidX, required bool multidexEnabled, }) async { globals.printBox( '${globals.logger.terminal.warningMark} Gradle does not have execution permission.\n' 'You should change the ownership of the project directory to your user, ' 'or move the project to a directory with execute permissions.', title: _boxTitle, ); return GradleBuildStatus.exit; }, eventLabel: 'permission-denied', ); /// Gradle crashes for several known reasons when downloading that are not /// actionable by Flutter. @visibleForTesting final GradleHandledError networkErrorHandler = GradleHandledError( test: _lineMatcher(const <String>[ 'java.io.FileNotFoundException: https://downloads.gradle.org', 'java.io.IOException: Unable to tunnel through proxy', 'java.lang.RuntimeException: Timeout of', 'java.util.zip.ZipException: error in opening zip file', 'javax.net.ssl.SSLHandshakeException: Remote host closed connection during handshake', 'java.net.SocketException: Connection reset', 'java.io.FileNotFoundException', "> Could not get resource 'http", ]), handler: ({ required String line, required FlutterProject project, required bool usesAndroidX, required bool multidexEnabled, }) async { globals.printError( '${globals.logger.terminal.warningMark} ' 'Gradle threw an error while downloading artifacts from the network.' ); return GradleBuildStatus.retry; }, eventLabel: 'network', ); /// Handles corrupted jar or other types of zip files. /// /// If a terminal is attached, this handler prompts the user if they would like to /// delete the $HOME/.gradle directory prior to retrying the build. /// /// If this handler runs on a bot (e.g. a CI bot), the $HOME/.gradle is automatically deleted. /// /// See also: /// * https://github.com/flutter/flutter/issues/51195 /// * https://github.com/flutter/flutter/issues/89959 /// * https://docs.gradle.org/current/userguide/directory_layout.html#dir:gradle_user_home @visibleForTesting final GradleHandledError zipExceptionHandler = GradleHandledError( test: _lineMatcher(const <String>[ 'java.util.zip.ZipException: error in opening zip file', ]), handler: ({ required String line, required FlutterProject project, required bool usesAndroidX, required bool multidexEnabled, }) async { globals.printError( '${globals.logger.terminal.warningMark} ' 'Your .gradle directory under the home directory might be corrupted.' ); bool shouldDeleteUserGradle = await globals.botDetector.isRunningOnBot; if (!shouldDeleteUserGradle && globals.terminal.stdinHasTerminal) { try { final String selection = await globals.terminal.promptForCharInput( <String>['y', 'n'], logger: globals.logger, prompt: 'Do you want to delete the .gradle directory under the home directory?', defaultChoiceIndex: 0, ); shouldDeleteUserGradle = selection == 'y'; } on StateError catch (e) { globals.printError( e.message, indent: 0, ); } } if (shouldDeleteUserGradle) { final String? homeDir = globals.platform.environment['HOME']; if (homeDir == null) { globals.logger.printStatus("Could not delete .gradle directory because there isn't a HOME env variable"); return GradleBuildStatus.retry; } final Directory userGradle = globals.fs.directory(globals.fs.path.join(homeDir, '.gradle')); globals.logger.printStatus('Deleting ${userGradle.path}'); try { ErrorHandlingFileSystem.deleteIfExists(userGradle, recursive: true); } on FileSystemException catch (err) { globals.printTrace('Failed to delete Gradle cache: $err'); } } return GradleBuildStatus.retry; }, eventLabel: 'zip-exception', ); // R8 failure. @visibleForTesting final GradleHandledError r8FailureHandler = GradleHandledError( test: _lineMatcher(const <String>[ 'com.android.tools.r8', ]), handler: ({ required String line, required FlutterProject project, required bool usesAndroidX, required bool multidexEnabled, }) async { globals.printBox( '${globals.logger.terminal.warningMark} The shrinker may have failed to optimize the Java bytecode.\n' 'To disable the shrinker, pass the `--no-shrink` flag to this command.\n' 'To learn more, see: https://developer.android.com/studio/build/shrink-code', title: _boxTitle, ); return GradleBuildStatus.exit; }, eventLabel: 'r8', ); /// 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. @visibleForTesting final GradleHandledError licenseNotAcceptedHandler = GradleHandledError( test: _lineMatcher(const <String>[ 'You have not accepted the license agreements of the following SDK components', ]), handler: ({ required String line, required FlutterProject project, required bool usesAndroidX, required bool multidexEnabled, }) async { const String licenseNotAcceptedMatcher = r'You have not accepted the license agreements of the following SDK components:\s*\[(.+)\]'; final RegExp licenseFailure = RegExp(licenseNotAcceptedMatcher, multiLine: true); final Match? licenseMatch = licenseFailure.firstMatch(line); globals.printBox( '${globals.logger.terminal.warningMark} Unable to download needed Android SDK components, as the ' 'following licenses have not been accepted: ' '${licenseMatch?.group(1)}\n\n' 'To resolve this, please run the following command in a Terminal:\n' 'flutter doctor --android-licenses', title: _boxTitle, ); return GradleBuildStatus.exit; }, eventLabel: 'license-not-accepted', ); final RegExp _undefinedTaskPattern = RegExp(r'Task .+ not found in root project.'); final RegExp _assembleTaskPattern = RegExp(r'assemble(\S+)'); /// Handler when a flavor is undefined. @visibleForTesting final GradleHandledError flavorUndefinedHandler = GradleHandledError( test: (String line) { return _undefinedTaskPattern.hasMatch(line); }, handler: ({ required String line, required FlutterProject project, required bool usesAndroidX, required bool multidexEnabled, }) async { final RunResult tasksRunResult = await globals.processUtils.run( <String>[ globals.gradleUtils!.getExecutable(project), 'app:tasks' , '--all', '--console=auto', ], throwOnError: true, workingDirectory: project.android.hostAppGradleRoot.path, environment: globals.java?.environment, ); // Extract build types and product flavors. final Set<String> variants = <String>{}; for (final String task in tasksRunResult.stdout.split('\n')) { final Match? match = _assembleTaskPattern.matchAsPrefix(task); if (match != null) { final String variant = match.group(1)!.toLowerCase(); if (!variant.endsWith('test')) { variants.add(variant); } } } final Set<String> productFlavors = <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)) { productFlavors.add(variant1); } } } } final String errorMessage = '${globals.logger.terminal.warningMark} Gradle project does not define a task suitable for the requested build.'; final File buildGradle = project.directory.childDirectory('android').childDirectory('app').childFile('build.gradle'); if (productFlavors.isEmpty) { globals.printBox( '$errorMessage\n\n' 'The ${buildGradle.absolute.path} file does not define ' 'any custom product flavors. ' 'You cannot use the --flavor option.', title: _boxTitle, ); } else { globals.printBox( '$errorMessage\n\n' 'The ${buildGradle.absolute.path} file defines product ' 'flavors: ${productFlavors.join(', ')}. ' 'You must specify a --flavor option to select one of them.', title: _boxTitle, ); } return GradleBuildStatus.exit; }, eventLabel: 'flavor-undefined', ); final RegExp _minSdkVersionPattern = RegExp(r'uses-sdk:minSdkVersion ([0-9]+) cannot be smaller than version ([0-9]+) declared in library \[\:(.+)\]'); /// Handler when a plugin requires a higher Android API level. @visibleForTesting final GradleHandledError minSdkVersionHandler = GradleHandledError( test: (String line) { return _minSdkVersionPattern.hasMatch(line); }, handler: ({ required String line, required FlutterProject project, required bool usesAndroidX, required bool multidexEnabled, }) async { final File gradleFile = project.directory .childDirectory('android') .childDirectory('app') .childFile('build.gradle'); final Match? minSdkVersionMatch = _minSdkVersionPattern.firstMatch(line); assert(minSdkVersionMatch?.groupCount == 3); final String textInBold = globals.logger.terminal.bolden( 'Fix this issue by adding the following to the file ${gradleFile.path}:\n' 'android {\n' ' defaultConfig {\n' ' minSdkVersion ${minSdkVersionMatch?.group(2)}\n' ' }\n' '}\n' ); globals.printBox( 'The plugin ${minSdkVersionMatch?.group(3)} requires a higher Android SDK version.\n' '$textInBold\n' 'Following this change, your app will not be available to users running Android SDKs below ${minSdkVersionMatch?.group(2)}.\n' 'Consider searching for a version of this plugin that supports these lower versions of the Android SDK instead.\n' 'For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration', title: _boxTitle, ); return GradleBuildStatus.exit; }, eventLabel: 'plugin-min-sdk', ); /// Handler when https://issuetracker.google.com/issues/141126614 or /// https://github.com/flutter/flutter/issues/58247 is triggered. @visibleForTesting final GradleHandledError transformInputIssueHandler = GradleHandledError( test: (String line) { return line.contains('https://issuetracker.google.com/issues/158753935'); }, handler: ({ required String line, required FlutterProject project, required bool usesAndroidX, required bool multidexEnabled, }) async { final File gradleFile = project.directory .childDirectory('android') .childDirectory('app') .childFile('build.gradle'); final String textInBold = globals.logger.terminal.bolden( 'Fix this issue by adding the following to the file ${gradleFile.path}:\n' 'android {\n' ' lintOptions {\n' ' checkReleaseBuilds false\n' ' }\n' '}' ); globals.printBox( 'This issue appears to be https://github.com/flutter/flutter/issues/58247.\n' '$textInBold', title: _boxTitle, ); return GradleBuildStatus.exit; }, eventLabel: 'transform-input-issue', ); /// Handler when a dependency is missing in the lockfile. @visibleForTesting final GradleHandledError lockFileDepMissingHandler = GradleHandledError( test: (String line) { return line.contains('which is not part of the dependency lock state'); }, handler: ({ required String line, required FlutterProject project, required bool usesAndroidX, required bool multidexEnabled, }) async { final File gradleFile = project.directory .childDirectory('android') .childFile('build.gradle'); final String textInBold = globals.logger.terminal.bolden( 'To regenerate the lockfiles run: `./gradlew :generateLockfiles` in ${gradleFile.path}\n' 'To remove dependency locking, remove the `dependencyLocking` from ${gradleFile.path}' ); globals.printBox( 'You need to update the lockfile, or disable Gradle dependency locking.\n' '$textInBold', title: _boxTitle, ); return GradleBuildStatus.exit; }, eventLabel: 'lock-dep-issue', ); @visibleForTesting final GradleHandledError incompatibleKotlinVersionHandler = GradleHandledError( test: _lineMatcher(const <String>[ 'was compiled with an incompatible version of Kotlin', ]), handler: ({ required String line, required FlutterProject project, required bool usesAndroidX, required bool multidexEnabled, }) async { final File gradleFile = project.directory .childDirectory('android') .childFile('build.gradle'); globals.printBox( '${globals.logger.terminal.warningMark} Your project requires a newer version of the Kotlin Gradle plugin.\n' 'Find the latest version on https://kotlinlang.org/docs/releases.html#release-details, then update ${gradleFile.path}:\n' "ext.kotlin_version = '<latest-version>'", title: _boxTitle, ); return GradleBuildStatus.exit; }, eventLabel: 'incompatible-kotlin-version', ); final RegExp _outdatedGradlePattern = RegExp(r'The current Gradle version (.+) is not compatible with the Kotlin Gradle plugin'); @visibleForTesting final GradleHandledError outdatedGradleHandler = GradleHandledError( test: _outdatedGradlePattern.hasMatch, handler: ({ required String line, required FlutterProject project, required bool usesAndroidX, required bool multidexEnabled, }) async { final File gradleFile = project.directory .childDirectory('android') .childFile('build.gradle'); final File gradlePropertiesFile = project.directory .childDirectory('android') .childDirectory('gradle') .childDirectory('wrapper') .childFile('gradle-wrapper.properties'); globals.printBox( '${globals.logger.terminal.warningMark} Your project needs to upgrade Gradle and the Android Gradle plugin.\n\n' 'To fix this issue, replace the following content:\n' '${gradleFile.path}:\n' ' ${globals.terminal.color("- classpath 'com.android.tools.build:gradle:<current-version>'", TerminalColor.red)}\n' ' ${globals.terminal.color("+ classpath 'com.android.tools.build:gradle:$templateAndroidGradlePluginVersion'", TerminalColor.green)}\n' '${gradlePropertiesFile.path}:\n' ' ${globals.terminal.color('- https://services.gradle.org/distributions/gradle-<current-version>-all.zip', TerminalColor.red)}\n' ' ${globals.terminal.color('+ https://services.gradle.org/distributions/gradle-$templateDefaultGradleVersion-all.zip', TerminalColor.green)}', title: _boxTitle, ); return GradleBuildStatus.exit; }, eventLabel: 'outdated-gradle-version', ); final RegExp _minCompileSdkVersionPattern = RegExp(r'The minCompileSdk \(([0-9]+)\) specified in a'); @visibleForTesting final GradleHandledError minCompileSdkVersionHandler = GradleHandledError( test: _minCompileSdkVersionPattern.hasMatch, handler: ({ required String line, required FlutterProject project, required bool usesAndroidX, required bool multidexEnabled, }) async { final Match? minCompileSdkVersionMatch = _minCompileSdkVersionPattern.firstMatch(line); assert(minCompileSdkVersionMatch?.groupCount == 1); final File gradleFile = project.directory .childDirectory('android') .childDirectory('app') .childFile('build.gradle'); globals.printBox( '${globals.logger.terminal.warningMark} Your project requires a higher compileSdk version.\n' 'Fix this issue by bumping the compileSdk version in ${gradleFile.path}:\n' 'android {\n' ' compileSdk ${minCompileSdkVersionMatch?.group(1)}\n' '}', title: _boxTitle, ); return GradleBuildStatus.exit; }, eventLabel: 'min-compile-sdk-version', ); @visibleForTesting final GradleHandledError jvm11RequiredHandler = GradleHandledError( test: (String line) { return line.contains('Android Gradle plugin requires Java 11 to run'); }, handler: ({ required String line, required FlutterProject project, required bool usesAndroidX, required bool multidexEnabled, }) async { globals.printBox( '${globals.logger.terminal.warningMark} You need Java 11 or higher to build your app with this version of Gradle.\n\n' 'To get Java 11, update to the latest version of Android Studio on https://developer.android.com/studio/install.\n\n' 'To check the Java version used by Flutter, run `flutter doctor -v`.', title: _boxTitle, ); return GradleBuildStatus.exit; }, eventLabel: 'java11-required', ); /// Handles SSL exceptions: https://github.com/flutter/flutter/issues/104628 @visibleForTesting final GradleHandledError sslExceptionHandler = GradleHandledError( test: _lineMatcher(const <String>[ 'javax.net.ssl.SSLException: Tag mismatch!', 'javax.crypto.AEADBadTagException: Tag mismatch!', ]), handler: ({ required String line, required FlutterProject project, required bool usesAndroidX, required bool multidexEnabled, }) async { globals.printError( '${globals.logger.terminal.warningMark} ' 'Gradle threw an error while downloading artifacts from the network.' ); return GradleBuildStatus.retry; }, eventLabel: 'ssl-exception-tag-mismatch', ); /// If an incompatible Java and Gradle versions error is caught, we expect an /// error specifying that the Java major class file version, one of /// https://javaalmanac.io/bytecode/versions/, is unsupported by Gradle. final RegExp _unsupportedClassFileMajorVersionPattern = RegExp(r'Unsupported class file major version\s+\d+'); @visibleForTesting final GradleHandledError incompatibleJavaAndGradleVersionsHandler = GradleHandledError( test: (String line) { return _unsupportedClassFileMajorVersionPattern.hasMatch(line); }, handler: ({ required String line, required FlutterProject project, required bool usesAndroidX, required bool multidexEnabled, }) async { final File gradlePropertiesFile = project.directory .childDirectory('android') .childDirectory('gradle') .childDirectory('wrapper') .childFile('gradle-wrapper.properties'); // TODO(reidbaker): Replace URL with constant defined in // https://github.com/flutter/flutter/pull/123916. globals.printBox( "${globals.logger.terminal.warningMark} Your project's Gradle version " 'is incompatible with the Java version that Flutter is using for Gradle.\n\n' 'If you recently upgraded Android Studio, consult the migration guide ' 'at docs.flutter.dev/go/android-java-gradle-error.\n\n' 'Otherwise, to fix this issue, first, check the Java version used by Flutter by ' 'running `flutter doctor --verbose`.\n\n' 'Then, update the Gradle version specified in ${gradlePropertiesFile.path} ' 'to be compatible with that Java version. ' 'See the link below for more information on compatible Java/Gradle versions:\n' 'https://docs.gradle.org/current/userguide/compatibility.html#java\n\n', title: _boxTitle, ); return GradleBuildStatus.exit; }, eventLabel: 'incompatible-java-gradle-version', ); @visibleForTesting final GradleHandledError remoteTerminatedHandshakeHandler = GradleHandledError( test: (String line) => line.contains('Remote host terminated the handshake'), handler: ({ required String line, required FlutterProject project, required bool usesAndroidX, required bool multidexEnabled, }) async { globals.printError( '${globals.logger.terminal.warningMark} ' 'Gradle threw an error while downloading artifacts from the network.' ); return GradleBuildStatus.retry; }, eventLabel: 'remote-terminated-handshake', ); @visibleForTesting final GradleHandledError couldNotOpenCacheDirectoryHandler = GradleHandledError( test: (String line) => line.contains('> Could not open cache directory '), handler: ({ required String line, required FlutterProject project, required bool usesAndroidX, required bool multidexEnabled, }) async { globals.printError( '${globals.logger.terminal.warningMark} ' 'Gradle threw an error while resolving dependencies.' ); return GradleBuildStatus.retry; }, eventLabel: 'could-not-open-cache-directory', );