diff --git a/packages/flutter_tools/gradle/flutter.gradle b/packages/flutter_tools/gradle/flutter.gradle index 5a9131d7e3..b1b9148635 100644 --- a/packages/flutter_tools/gradle/flutter.gradle +++ b/packages/flutter_tools/gradle/flutter.gradle @@ -215,6 +215,38 @@ class FlutterPlugin implements Plugin { } } + if (project.hasProperty("multidex-enabled") && + project.property("multidex-enabled").toBoolean() && + project.android.defaultConfig.minSdkVersion <= 20) { + String flutterMultidexKeepfile = Paths.get(flutterRoot.absolutePath, "packages", "flutter_tools", + "gradle", "flutter_multidex_keepfile.txt") + project.android { + defaultConfig { + multiDexEnabled true + manifestPlaceholders = [applicationName: "io.flutter.app.FlutterMultiDexApplication"] + } + buildTypes { + release { + multiDexKeepFile project.file(flutterMultidexKeepfile) + } + } + } + project.dependencies { + implementation "androidx.multidex:multidex:2.0.1" + } + } else { + String baseApplicationName = "android.app.Application" + if (project.hasProperty("base-application-name")) { + baseApplicationName = project.property("base-application-name") + } + project.android { + defaultConfig { + // Setting to android.app.Application is the same as omitting the attribute. + manifestPlaceholders = [applicationName: baseApplicationName] + } + } + } + if (useLocalEngine()) { // This is required to pass the local engine to flutter build aot. String engineOutPath = project.property('local-engine-out') diff --git a/packages/flutter_tools/gradle/flutter_multidex_keepfile.txt b/packages/flutter_tools/gradle/flutter_multidex_keepfile.txt new file mode 100644 index 0000000000..34984e6fa5 --- /dev/null +++ b/packages/flutter_tools/gradle/flutter_multidex_keepfile.txt @@ -0,0 +1,5 @@ +io/flutter/app/FlutterApplication.class +io/flutter/app/FlutterMultiDexApplication.class +io/flutter/embedding/engine/loader/FlutterLoader.class +io/flutter/view/FlutterMain.class +io/flutter/util/PathUtils.class diff --git a/packages/flutter_tools/lib/src/android/android_device.dart b/packages/flutter_tools/lib/src/android/android_device.dart index 7bdd848316..4470ff5f54 100644 --- a/packages/flutter_tools/lib/src/android/android_device.dart +++ b/packages/flutter_tools/lib/src/android/android_device.dart @@ -595,7 +595,8 @@ class AndroidDevice extends Device { androidBuildInfo: AndroidBuildInfo( debuggingOptions.buildInfo, targetArchs: [androidArch], - fastStart: debuggingOptions.fastStart + fastStart: debuggingOptions.fastStart, + multidexEnabled: platformArgs['multidex'] as bool, ), ); // Package has been built, so we can get the updated application ID and diff --git a/packages/flutter_tools/lib/src/android/gradle.dart b/packages/flutter_tools/lib/src/android/gradle.dart index be013075f0..fafe472665 100644 --- a/packages/flutter_tools/lib/src/android/gradle.dart +++ b/packages/flutter_tools/lib/src/android/gradle.dart @@ -28,6 +28,7 @@ import 'android_builder.dart'; import 'android_studio.dart'; import 'gradle_errors.dart'; import 'gradle_utils.dart'; +import 'multidex.dart'; /// The directory where the APK artifact is generated. Directory getApkDirectory(FlutterProject project) { @@ -293,6 +294,22 @@ class AndroidGradleBuilder implements AndroidBuilder { if (target != null) { command.add('-Ptarget=$target'); } + // Only attempt adding multidex support if all the flutter generated files exist. + // If the files do not exist and it was unintentional, the app will fail to build + // and prompt the developer if they wish Flutter to add the files again via gradle_error.dart. + if (androidBuildInfo.multidexEnabled && + multiDexApplicationExists(project.directory) && + androidManifestHasNameVariable(project.directory)) { + command.add('-Pmultidex-enabled=true'); + ensureMultiDexApplicationExists(project.directory); + _logger.printStatus('Building with Flutter multidex support enabled.'); + } + // If using v1 embedding, we want to use FlutterApplication as the base app. + final String baseApplicationName = + project.android.getEmbeddingVersion() == AndroidEmbeddingVersion.v2 ? + 'android.app.Application' : + 'io.flutter.app.FlutterApplication'; + command.add('-Pbase-application-name=$baseApplicationName'); final List? deferredComponents = project.manifest.deferredComponents; if (deferredComponents != null) { if (deferredComponentsEnabled) { @@ -389,6 +406,7 @@ class AndroidGradleBuilder implements AndroidBuilder { line: detectedGradleErrorLine!, project: project, usesAndroidX: usesAndroidX, + multidexEnabled: androidBuildInfo.multidexEnabled, ); if (retries >= 1) { diff --git a/packages/flutter_tools/lib/src/android/gradle_errors.dart b/packages/flutter_tools/lib/src/android/gradle_errors.dart index 2b94ef9b14..32775273d0 100644 --- a/packages/flutter_tools/lib/src/android/gradle_errors.dart +++ b/packages/flutter_tools/lib/src/android/gradle_errors.dart @@ -7,10 +7,12 @@ 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_null_migrated.dart' as globals; import '../project.dart'; import '../reporting/reporting.dart'; import 'android_studio.dart'; +import 'multidex.dart'; typedef GradleErrorTest = bool Function(String); @@ -31,6 +33,7 @@ class GradleHandledError { required String line, required FlutterProject project, required bool usesAndroidX, + required bool multidexEnabled, }) handler; /// The [BuildEvent] label is named gradle-[eventLabel]. @@ -71,8 +74,104 @@ final List gradleErrors = [ minSdkVersion, transformInputIssue, lockFileDepMissing, + multidexErrorHandler, ]; +// Multidex error message. +@visibleForTesting +final GradleHandledError multidexErrorHandler = GradleHandledError( + test: _lineMatcher(const [ + '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. ' + "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''' + +''', + 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( + ['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.printStatus( + 'Flutter multidex handling is disabled. If you wish to let the tool configure multidex, use the --mutidex flag.', + indent: 4, + ); + } + return GradleBuildStatus.exit; + }, + eventLabel: 'multidex-error', +); + // Permission defined error message. @visibleForTesting final GradleHandledError permissionDeniedErrorHandler = GradleHandledError( @@ -83,12 +182,13 @@ final GradleHandledError permissionDeniedErrorHandler = GradleHandledError( required String line, required FlutterProject project, required bool usesAndroidX, + required bool multidexEnabled, }) async { globals.printStatus('${globals.logger.terminal.warningMark} Gradle does not have execution permission.', emphasis: true); globals.printStatus( 'You should change the ownership of the project directory to your user, ' 'or move the project to a directory with execute permissions.', - indent: 4 + indent: 4, ); return GradleBuildStatus.exit; }, @@ -119,6 +219,7 @@ final GradleHandledError networkErrorHandler = GradleHandledError( 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. ' @@ -148,6 +249,7 @@ final GradleHandledError r8FailureHandler = GradleHandledError( required String line, required FlutterProject project, required bool usesAndroidX, + required bool multidexEnabled, }) async { globals.printStatus('${globals.logger.terminal.warningMark} The shrinker may have failed to optimize the Java bytecode.', emphasis: true); globals.printStatus('To disable the shrinker, pass the `--no-shrink` flag to this command.', indent: 4); @@ -169,6 +271,7 @@ final GradleHandledError licenseNotAcceptedHandler = GradleHandledError( 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*\[(.+)\]'; @@ -202,6 +305,7 @@ final GradleHandledError flavorUndefinedHandler = GradleHandledError( required String line, required FlutterProject project, required bool usesAndroidX, + required bool multidexEnabled, }) async { final RunResult tasksRunResult = await globals.processUtils.run( [ @@ -274,6 +378,7 @@ final GradleHandledError minSdkVersion = GradleHandledError( required String line, required FlutterProject project, required bool usesAndroidX, + required bool multidexEnabled, }) async { final File gradleFile = project.directory .childDirectory('android') @@ -314,6 +419,7 @@ final GradleHandledError transformInputIssue = GradleHandledError( required String line, required FlutterProject project, required bool usesAndroidX, + required bool multidexEnabled, }) async { final File gradleFile = project.directory .childDirectory('android') @@ -347,6 +453,7 @@ final GradleHandledError lockFileDepMissing = GradleHandledError( required String line, required FlutterProject project, required bool usesAndroidX, + required bool multidexEnabled, }) async { final File gradleFile = project.directory .childDirectory('android') diff --git a/packages/flutter_tools/lib/src/android/multidex.dart b/packages/flutter_tools/lib/src/android/multidex.dart new file mode 100644 index 0000000000..a016e0a1ae --- /dev/null +++ b/packages/flutter_tools/lib/src/android/multidex.dart @@ -0,0 +1,99 @@ +// 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:xml/xml.dart'; + +import '../base/file_system.dart'; + +// These utility methods are used to generate the code for multidex support as +// well as verifying the project is properly set up. + +File _getMultiDexApplicationFile(Directory projectDir) { + return projectDir.childDirectory('android') + .childDirectory('app') + .childDirectory('src') + .childDirectory('main') + .childDirectory('java') + .childDirectory('io') + .childDirectory('flutter') + .childDirectory('app') + .childFile('FlutterMultiDexApplication.java'); +} + +/// Creates the FlutterMultiDexApplication.java if it does not exist. +void ensureMultiDexApplicationExists(final Directory projectDir) { + final File applicationFile = _getMultiDexApplicationFile(projectDir); + if (applicationFile.existsSync()) { + return; + } + applicationFile.createSync(recursive: true); + + final StringBuffer buffer = StringBuffer(); + buffer.write(''' +// Generated file. +// If you wish to remove Flutter's multidex support, delete this entire file. + +package io.flutter.app; + +import android.content.Context; +import androidx.annotation.CallSuper; +import androidx.multidex.MultiDex; + +/** + * Extension of {@link io.flutter.app.FlutterApplication}, adding multidex support. + */ +public class FlutterMultiDexApplication extends FlutterApplication { + @Override + @CallSuper + protected void attachBaseContext(Context base) { + super.attachBaseContext(base); + MultiDex.install(this); + } +} +'''); + applicationFile.writeAsStringSync(buffer.toString(), flush: true); +} + +/// Returns true if FlutterMultiDexApplication.java exists. +/// +/// This function does not verify the contents of the file. +bool multiDexApplicationExists(final Directory projectDir) { + if (_getMultiDexApplicationFile(projectDir).existsSync()) { + return true; + } + return false; +} + +File _getManifestFile(Directory projectDir) { + return projectDir.childDirectory('android') + .childDirectory('app') + .childDirectory('src') + .childDirectory('main') + .childFile('AndroidManifest.xml'); +} + +/// Returns true if the `app` module AndroidManifest.xml includes the +/// attribute. +bool androidManifestHasNameVariable(final Directory projectDir) { + final File manifestFile = _getManifestFile(projectDir); + if (!manifestFile.existsSync()) { + return false; + } + XmlDocument document; + try { + document = XmlDocument.parse(manifestFile.readAsStringSync()); + } on XmlParserException { + return false; + } on FileSystemException { + return false; + } + // Check for the ${androidName} application attribute. + for (final XmlElement application in document.findAllElements('application')) { + final String? applicationName = application.getAttribute('android:name'); + if (applicationName == r'${applicationName}') { + return true; + } + } + return false; +} diff --git a/packages/flutter_tools/lib/src/build_info.dart b/packages/flutter_tools/lib/src/build_info.dart index 9f62ce4a8a..765e9f851c 100644 --- a/packages/flutter_tools/lib/src/build_info.dart +++ b/packages/flutter_tools/lib/src/build_info.dart @@ -304,6 +304,7 @@ class AndroidBuildInfo { ], this.splitPerAbi = false, this.fastStart = false, + this.multidexEnabled = false, }); // The build info containing the mode and flavor. @@ -321,6 +322,9 @@ class AndroidBuildInfo { /// Whether to bootstrap an empty application. final bool fastStart; + + /// Whether to enable multidex support for apps with more than 64k methods. + final bool multidexEnabled; } /// A summary of the compilation strategy used for Dart. diff --git a/packages/flutter_tools/lib/src/commands/build_apk.dart b/packages/flutter_tools/lib/src/commands/build_apk.dart index c335b6a536..63caab55c5 100644 --- a/packages/flutter_tools/lib/src/commands/build_apk.dart +++ b/packages/flutter_tools/lib/src/commands/build_apk.dart @@ -35,6 +35,7 @@ class BuildApkCommand extends BuildSubCommand { addNullSafetyModeOptions(hide: !verboseHelp); usesAnalyzeSizeFlag(); addAndroidSpecificBuildOptions(hide: !verboseHelp); + addMultidexOption(); argParser ..addFlag('split-per-abi', negatable: false, @@ -99,9 +100,11 @@ class BuildApkCommand extends BuildSubCommand { buildInfo, splitPerAbi: boolArg('split-per-abi'), targetArchs: stringsArg('target-platform').map(getAndroidArchForName), + multidexEnabled: boolArg('multidex'), ); validateBuild(androidBuildInfo); displayNullSafetyMode(androidBuildInfo.buildInfo); + globals.terminal.usesTerminalUi = true; await androidBuilder.buildApk( project: FlutterProject.current(), target: targetFile, diff --git a/packages/flutter_tools/lib/src/commands/build_appbundle.dart b/packages/flutter_tools/lib/src/commands/build_appbundle.dart index 329267a9ae..7d9a2821cc 100644 --- a/packages/flutter_tools/lib/src/commands/build_appbundle.dart +++ b/packages/flutter_tools/lib/src/commands/build_appbundle.dart @@ -39,6 +39,7 @@ class BuildAppBundleCommand extends BuildSubCommand { addEnableExperimentation(hide: !verboseHelp); usesAnalyzeSizeFlag(); addAndroidSpecificBuildOptions(hide: !verboseHelp); + addMultidexOption(); argParser.addMultiOption('target-platform', splitCommas: true, defaultsTo: ['android-arm', 'android-arm64', 'android-x64'], @@ -110,6 +111,7 @@ class BuildAppBundleCommand extends BuildSubCommand { final AndroidBuildInfo androidBuildInfo = AndroidBuildInfo(await getBuildInfo(), targetArchs: stringsArg('target-platform').map(getAndroidArchForName), + multidexEnabled: boolArg('multidex'), ); // Do all setup verification that doesn't involve loading units. Checks that // require generated loading units are done after gen_snapshot in assemble. @@ -144,6 +146,7 @@ class BuildAppBundleCommand extends BuildSubCommand { validateBuild(androidBuildInfo); displayNullSafetyMode(androidBuildInfo.buildInfo); + globals.terminal.usesTerminalUi = true; await androidBuilder.buildAab( project: FlutterProject.current(), target: targetFile, diff --git a/packages/flutter_tools/lib/src/commands/drive.dart b/packages/flutter_tools/lib/src/commands/drive.dart index fdb30d02da..2084b79a7b 100644 --- a/packages/flutter_tools/lib/src/commands/drive.dart +++ b/packages/flutter_tools/lib/src/commands/drive.dart @@ -65,6 +65,7 @@ class DriveCommand extends RunCommandBase { // to prevent a local network permission dialog on iOS 14+, // which cannot be accepted or dismissed in a CI environment. addPublishPort(enabledByDefault: false, verboseHelp: verboseHelp); + addMultidexOption(); argParser ..addFlag('keep-app-running', defaultsTo: null, @@ -251,6 +252,8 @@ class DriveCommand extends RunCommandBase { 'trace-startup': traceStartup, if (web) '--no-launch-chrome': true, + if (boolArg('multidex')) + 'multidex': true, } ); } else { diff --git a/packages/flutter_tools/lib/src/commands/run.dart b/packages/flutter_tools/lib/src/commands/run.dart index 3f1e025719..0c65258dca 100644 --- a/packages/flutter_tools/lib/src/commands/run.dart +++ b/packages/flutter_tools/lib/src/commands/run.dart @@ -249,6 +249,7 @@ class RunCommand extends RunCommandBase { // This will allow subsequent "flutter attach" commands to connect to the VM // without needing to know the port. addPublishPort(verboseHelp: verboseHelp); + addMultidexOption(); argParser ..addFlag('enable-software-rendering', negatable: false, @@ -500,6 +501,7 @@ class RunCommand extends RunCommandBase { dillOutputPath: stringArg('output-dill'), stayResident: stayResident, ipv6: ipv6, + multidexEnabled: boolArg('multidex'), ); } else if (webMode) { return webRunnerFactory.createWebRunner( diff --git a/packages/flutter_tools/lib/src/resident_runner.dart b/packages/flutter_tools/lib/src/resident_runner.dart index 686cd92ce7..607500e0a1 100644 --- a/packages/flutter_tools/lib/src/resident_runner.dart +++ b/packages/flutter_tools/lib/src/resident_runner.dart @@ -416,7 +416,9 @@ class FlutterDevice { } devFSWriter = device.createDevFSWriter(package, userIdentifier); - final Map platformArgs = {}; + final Map platformArgs = { + 'multidex': hotRunner.multidexEnabled, + }; await startEchoingDeviceLog(); diff --git a/packages/flutter_tools/lib/src/run_hot.dart b/packages/flutter_tools/lib/src/run_hot.dart index e81bf73d64..556347315b 100644 --- a/packages/flutter_tools/lib/src/run_hot.dart +++ b/packages/flutter_tools/lib/src/run_hot.dart @@ -93,6 +93,7 @@ class HotRunner extends ResidentRunner { bool stayResident = true, bool ipv6 = false, bool machine = false, + this.multidexEnabled = false, ResidentDevtoolsHandlerFactory devtoolsHandler = createDefaultHandler, StopwatchFactory stopwatchFactory = const StopwatchFactory(), ReloadSourcesHelper reloadSourcesHelper = _defaultReloadSourcesHelper, @@ -120,6 +121,7 @@ class HotRunner extends ResidentRunner { final bool benchmarkMode; final File applicationBinary; final bool hostIsIde; + final bool multidexEnabled; /// When performing a hot restart, the tool needs to upload a new main.dart.dill to /// each attached device's devfs. Replacing the existing file is not safe and does diff --git a/packages/flutter_tools/lib/src/runner/flutter_command.dart b/packages/flutter_tools/lib/src/runner/flutter_command.dart index f95dda393f..98d0b912bf 100644 --- a/packages/flutter_tools/lib/src/runner/flutter_command.dart +++ b/packages/flutter_tools/lib/src/runner/flutter_command.dart @@ -818,6 +818,16 @@ abstract class FlutterCommand extends Command { ); } + void addMultidexOption({ bool hide = false }) { + argParser.addFlag('multidex', + negatable: true, + defaultsTo: true, + help: 'When enabled, indicates that the app should be built with multidex support. This ' + 'flag adds the dependencies for multidex when the minimum android sdk is 20 or ' + 'below. For android sdk versions 21 and above, multidex support is native.', + ); + } + /// Adds build options common to all of the desktop build commands. void addCommonDesktopBuildOptions({ @required bool verboseHelp }) { addBuildModeFlags(verboseHelp: verboseHelp); diff --git a/packages/flutter_tools/templates/app_shared/android.tmpl/app/src/main/AndroidManifest.xml.tmpl b/packages/flutter_tools/templates/app_shared/android.tmpl/app/src/main/AndroidManifest.xml.tmpl index 4f1724e56a..e20e2be6db 100644 --- a/packages/flutter_tools/templates/app_shared/android.tmpl/app/src/main/AndroidManifest.xml.tmpl +++ b/packages/flutter_tools/templates/app_shared/android.tmpl/app/src/main/AndroidManifest.xml.tmpl @@ -2,6 +2,7 @@ package="{{androidIdentifier}}"> 65536) + at com.android.tools.r8.utils.T0.error(SourceFile:1) + at com.android.tools.r8.utils.T0.a(SourceFile:2) + at com.android.tools.r8.dex.P.a(SourceFile:740) + at com.android.tools.r8.dex.P$h.a(SourceFile:7) + at com.android.tools.r8.dex.b.a(SourceFile:14) + at com.android.tools.r8.dex.b.b(SourceFile:25) + at com.android.tools.r8.D8.d(D8.java:133) + at com.android.tools.r8.D8.b(D8.java:1) + at com.android.tools.r8.utils.Y.a(SourceFile:36) + ... 38 more + + +FAILURE: Build failed with an exception. + +* What went wrong: +Execution failed for task ':app:mergeDexDebug'. +> A failure occurred while executing com.android.build.gradle.internal.tasks.Workers$ActionFacade + > com.android.builder.dexing.DexArchiveMergerException: Error while merging dex archives: + The number of method references in a .dex file cannot exceed 64K. + Learn how to resolve this issue at https://developer.android.com/tools/building/multidex.html'''; + + expect(formatTestErrorMessage(errorMessage, multidexErrorHandler), isTrue); + expect(await multidexErrorHandler.handler(project: FlutterProject.fromDirectory(globals.fs.currentDirectory), multidexEnabled: true), equals(GradleBuildStatus.exit)); + + expect(testLogger.statusText, + contains( + 'Multidex support is required for your android app to build since the number of methods has exceeded 64k.' + ) + ); + expect(testLogger.statusText, + contains( + 'Your `android/app/src/main/AndroidManifest.xml` does not contain' + ) + ); + }, overrides: { + FileSystem: () => MemoryFileSystem.test(), + ProcessManager: () => FakeProcessManager.any(), + }); + testUsingContext('retries if multidex support enabled', () async { + const String errorMessage = r''' +Caused by: com.android.tools.r8.utils.b: Cannot fit requested classes in a single dex file (# methods: 85091 > 65536) + at com.android.tools.r8.utils.T0.error(SourceFile:1) + at com.android.tools.r8.utils.T0.a(SourceFile:2) + at com.android.tools.r8.dex.P.a(SourceFile:740) + at com.android.tools.r8.dex.P$h.a(SourceFile:7) + at com.android.tools.r8.dex.b.a(SourceFile:14) + at com.android.tools.r8.dex.b.b(SourceFile:25) + at com.android.tools.r8.D8.d(D8.java:133) + at com.android.tools.r8.D8.b(D8.java:1) + at com.android.tools.r8.utils.Y.a(SourceFile:36) + ... 38 more + + +FAILURE: Build failed with an exception. + +* What went wrong: +Execution failed for task ':app:mergeDexDebug'. +> A failure occurred while executing com.android.build.gradle.internal.tasks.Workers$ActionFacade + > com.android.builder.dexing.DexArchiveMergerException: Error while merging dex archives: + The number of method references in a .dex file cannot exceed 64K. + Learn how to resolve this issue at https://developer.android.com/tools/building/multidex.html'''; + + final File manifest = globals.fs.currentDirectory + .childDirectory('android') + .childDirectory('app') + .childDirectory('src') + .childDirectory('main') + .childFile('AndroidManifest.xml'); + manifest.createSync(recursive: true); + manifest.writeAsStringSync(r''' + + + + +''', flush: true); + + expect(formatTestErrorMessage(errorMessage, multidexErrorHandler), isTrue); + expect(await multidexErrorHandler.handler(project: FlutterProject.fromDirectory(globals.fs.currentDirectory), multidexEnabled: true), equals(GradleBuildStatus.retry)); + + expect(testLogger.statusText, + contains( + 'Multidex support is required for your android app to build since the number of methods has exceeded 64k.' + ) + ); + expect(testLogger.statusText, + contains( + 'android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java' + ) + ); + }, overrides: { + FileSystem: () => MemoryFileSystem.test(), + ProcessManager: () => FakeProcessManager.any(), + AnsiTerminal: () => _TestPromptTerminal('y') + }); + + testUsingContext('exits if multidex support skipped', () async { + const String errorMessage = r''' +Caused by: com.android.tools.r8.utils.b: Cannot fit requested classes in a single dex file (# methods: 85091 > 65536) + at com.android.tools.r8.utils.T0.error(SourceFile:1) + at com.android.tools.r8.utils.T0.a(SourceFile:2) + at com.android.tools.r8.dex.P.a(SourceFile:740) + at com.android.tools.r8.dex.P$h.a(SourceFile:7) + at com.android.tools.r8.dex.b.a(SourceFile:14) + at com.android.tools.r8.dex.b.b(SourceFile:25) + at com.android.tools.r8.D8.d(D8.java:133) + at com.android.tools.r8.D8.b(D8.java:1) + at com.android.tools.r8.utils.Y.a(SourceFile:36) + ... 38 more + + +FAILURE: Build failed with an exception. + +* What went wrong: +Execution failed for task ':app:mergeDexDebug'. +> A failure occurred while executing com.android.build.gradle.internal.tasks.Workers$ActionFacade + > com.android.builder.dexing.DexArchiveMergerException: Error while merging dex archives: + The number of method references in a .dex file cannot exceed 64K. + Learn how to resolve this issue at https://developer.android.com/tools/building/multidex.html'''; + + final File manifest = globals.fs.currentDirectory + .childDirectory('android') + .childDirectory('app') + .childDirectory('src') + .childDirectory('main') + .childFile('AndroidManifest.xml'); + manifest.createSync(recursive: true); + manifest.writeAsStringSync(r''' + + + + +''', flush: true); + + expect(formatTestErrorMessage(errorMessage, multidexErrorHandler), isTrue); + expect(await multidexErrorHandler.handler(project: FlutterProject.fromDirectory(globals.fs.currentDirectory), multidexEnabled: true), equals(GradleBuildStatus.exit)); + + expect(testLogger.statusText, + contains( + 'Multidex support is required for your android app to build since the number of methods has exceeded 64k.' + ) + ); + expect(testLogger.statusText, + contains( + 'Flutter tool can add multidex support. The following file will be added by flutter:' + ) + ); + expect(testLogger.statusText, + contains( + 'android/app/src/main/java/io/flutter/app/FlutterMultiDexApplication.java' + ) + ); + }, overrides: { + FileSystem: () => MemoryFileSystem.test(), + ProcessManager: () => FakeProcessManager.any(), + AnsiTerminal: () => _TestPromptTerminal('n') + }); + + testUsingContext('exits if multidex support disabled', () async { + const String errorMessage = r''' +Caused by: com.android.tools.r8.utils.b: Cannot fit requested classes in a single dex file (# methods: 85091 > 65536) + at com.android.tools.r8.utils.T0.error(SourceFile:1) + at com.android.tools.r8.utils.T0.a(SourceFile:2) + at com.android.tools.r8.dex.P.a(SourceFile:740) + at com.android.tools.r8.dex.P$h.a(SourceFile:7) + at com.android.tools.r8.dex.b.a(SourceFile:14) + at com.android.tools.r8.dex.b.b(SourceFile:25) + at com.android.tools.r8.D8.d(D8.java:133) + at com.android.tools.r8.D8.b(D8.java:1) + at com.android.tools.r8.utils.Y.a(SourceFile:36) + ... 38 more + + +FAILURE: Build failed with an exception. + +* What went wrong: +Execution failed for task ':app:mergeDexDebug'. +> A failure occurred while executing com.android.build.gradle.internal.tasks.Workers$ActionFacade + > com.android.builder.dexing.DexArchiveMergerException: Error while merging dex archives: + The number of method references in a .dex file cannot exceed 64K. + Learn how to resolve this issue at https://developer.android.com/tools/building/multidex.html'''; + + expect(formatTestErrorMessage(errorMessage, multidexErrorHandler), isTrue); + expect(await multidexErrorHandler.handler(project: FlutterProject.fromDirectory(globals.fs.currentDirectory), multidexEnabled: false), equals(GradleBuildStatus.exit)); + + expect(testLogger.statusText, + contains( + 'Flutter multidex handling is disabled.' + ) + ); + }, overrides: { + FileSystem: () => MemoryFileSystem.test(), + ProcessManager: () => FakeProcessManager.any(), + }); + }); + group('permission errors', () { testUsingContext('throws toolExit if gradle is missing execute permissions', () async { const String errorMessage = ''' @@ -667,3 +877,21 @@ class FakeGradleUtils extends GradleUtils { return 'gradlew'; } } + +/// Simple terminal that returns the specified string when +/// promptForCharInput is called. +class _TestPromptTerminal extends AnsiTerminal { + _TestPromptTerminal(this.promptResult); + + final String promptResult; + + @override + Future promptForCharInput(List acceptedCharacters, { + Logger logger, + String prompt, + int defaultChoiceIndex, + bool displayAcceptedCharacters = true, + }) { + return Future.value(promptResult); + } +} diff --git a/packages/flutter_tools/test/general.shard/android/multidex_test.dart b/packages/flutter_tools/test/general.shard/android/multidex_test.dart new file mode 100644 index 0000000000..a4cc8e871c --- /dev/null +++ b/packages/flutter_tools/test/general.shard/android/multidex_test.dart @@ -0,0 +1,189 @@ +// 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. + +// @dart = 2.8 + +import 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_tools/src/android/multidex.dart'; +import 'package:flutter_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/globals_null_migrated.dart' as globals; + +import '../../src/common.dart'; +import '../../src/context.dart'; + +void main() { + testUsingContext('ensureMultidexUtilsExists returns when exists', () async { + final Directory directory = globals.fs.currentDirectory; + final File applicationFile = directory.childDirectory('android') + .childDirectory('app') + .childDirectory('src') + .childDirectory('main') + .childDirectory('java') + .childDirectory('io') + .childDirectory('flutter') + .childDirectory('app') + .childFile('FlutterMultiDexApplication.java'); + applicationFile.createSync(recursive: true); + applicationFile.writeAsStringSync('hello', flush: true); + expect(applicationFile.readAsStringSync(), 'hello'); + + ensureMultiDexApplicationExists(directory); + + // File should remain untouched + expect(applicationFile.readAsStringSync(), 'hello'); + }, overrides: { + FileSystem: () => MemoryFileSystem.test(), + ProcessManager: () => FakeProcessManager.any(), + }); + + testUsingContext('ensureMultiDexApplicationExists generates when does not exist', () async { + final Directory directory = globals.fs.currentDirectory; + final File applicationFile = directory.childDirectory('android') + .childDirectory('app') + .childDirectory('src') + .childDirectory('main') + .childDirectory('java') + .childDirectory('io') + .childDirectory('flutter') + .childDirectory('app') + .childFile('FlutterMultiDexApplication.java'); + + ensureMultiDexApplicationExists(directory); + + final String contents = applicationFile.readAsStringSync(); + expect(contents.contains('FlutterMultiDexApplication'), true); + expect(contents.contains('MultiDex.install(this);'), true); + }, overrides: { + FileSystem: () => MemoryFileSystem.test(), + ProcessManager: () => FakeProcessManager.any(), + }); + + testUsingContext('multiDexApplicationExists false when does not exist', () async { + final Directory directory = globals.fs.currentDirectory; + expect(multiDexApplicationExists(directory), false); + }, overrides: { + FileSystem: () => MemoryFileSystem.test(), + ProcessManager: () => FakeProcessManager.any(), + }); + + testUsingContext('multiDexApplicationExists true when does exist', () async { + final Directory directory = globals.fs.currentDirectory; + final File utilsFile = directory.childDirectory('android') + .childDirectory('app') + .childDirectory('src') + .childDirectory('main') + .childDirectory('java') + .childDirectory('io') + .childDirectory('flutter') + .childDirectory('app') + .childFile('FlutterMultiDexApplication.java'); + utilsFile.createSync(recursive: true); + + expect(multiDexApplicationExists(directory), true); + }, overrides: { + FileSystem: () => MemoryFileSystem.test(), + ProcessManager: () => FakeProcessManager.any(), + }); + + testUsingContext('androidManifestHasNameVariable true with valid manifest', () async { + final Directory directory = globals.fs.currentDirectory; + final File applicationFile = directory.childDirectory('android') + .childDirectory('app') + .childDirectory('src') + .childDirectory('main') + .childFile('AndroidManifest.xml'); + applicationFile.createSync(recursive: true); + applicationFile.writeAsStringSync(r''' + + + + +''', flush: true); + expect(androidManifestHasNameVariable(directory), true); + }, overrides: { + FileSystem: () => MemoryFileSystem.test(), + ProcessManager: () => FakeProcessManager.any(), + }); + + testUsingContext('androidManifestHasNameVariable false with no android:name attribute', () async { + final Directory directory = globals.fs.currentDirectory; + final File applicationFile = directory.childDirectory('android') + .childDirectory('app') + .childDirectory('src') + .childDirectory('main') + .childFile('AndroidManifest.xml'); + applicationFile.createSync(recursive: true); + applicationFile.writeAsStringSync(r''' + + + +''', flush: true); + expect(androidManifestHasNameVariable(directory), false); + }, overrides: { + FileSystem: () => MemoryFileSystem.test(), + ProcessManager: () => FakeProcessManager.any(), + }); + + testUsingContext('androidManifestHasNameVariable false with incorrect android:name attribute', () async { + final Directory directory = globals.fs.currentDirectory; + final File applicationFile = directory.childDirectory('android') + .childDirectory('app') + .childDirectory('src') + .childDirectory('main') + .childFile('AndroidManifest.xml'); + applicationFile.createSync(recursive: true); + applicationFile.writeAsStringSync(r''' + + + +''', flush: true); + expect(androidManifestHasNameVariable(directory), false); + }, overrides: { + FileSystem: () => MemoryFileSystem.test(), + ProcessManager: () => FakeProcessManager.any(), + }); + + testUsingContext('androidManifestHasNameVariable false with invalid xml manifest', () async { + final Directory directory = globals.fs.currentDirectory; + final File applicationFile = directory.childDirectory('android') + .childDirectory('app') + .childDirectory('src') + .childDirectory('main') + .childFile('AndroidManifest.xml'); + applicationFile.createSync(recursive: true); + applicationFile.writeAsStringSync(r''' + + + +''', flush: true); + expect(androidManifestHasNameVariable(directory), false); + }, overrides: { + FileSystem: () => MemoryFileSystem.test(), + ProcessManager: () => FakeProcessManager.any(), + }); + + testUsingContext('androidManifestHasNameVariable false with no manifest file', () async { + final Directory directory = globals.fs.currentDirectory; + expect(androidManifestHasNameVariable(directory), false); + }, overrides: { + FileSystem: () => MemoryFileSystem.test(), + ProcessManager: () => FakeProcessManager.any(), + }); +} diff --git a/packages/flutter_tools/test/integration.shard/multidex_build_test.dart b/packages/flutter_tools/test/integration.shard/multidex_build_test.dart new file mode 100644 index 0000000000..21c43e1c00 --- /dev/null +++ b/packages/flutter_tools/test/integration.shard/multidex_build_test.dart @@ -0,0 +1,61 @@ +// 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. + +// @dart = 2.8 + +import 'package:file/file.dart'; +import 'package:flutter_tools/src/base/io.dart'; + +import '../src/common.dart'; +import 'test_data/multidex_project.dart'; +import 'test_driver.dart'; +import 'test_utils.dart'; + +void main() { + Directory tempDir; + FlutterRunTestDriver _flutter; + + setUp(() async { + tempDir = createResolvedTempDirectorySync('run_test.'); + _flutter = FlutterRunTestDriver(tempDir); + }); + + tearDown(() async { + await _flutter.stop(); + tryToDelete(tempDir); + }); + + testWithoutContext('simple build apk succeeds', () async { + final MultidexProject project = MultidexProject(true); + await project.setUpIn(tempDir); + final String flutterBin = fileSystem.path.join(getFlutterRoot(), 'bin', 'flutter'); + final ProcessResult result = await processManager.run([ + flutterBin, + ...getLocalEngineArguments(), + 'build', + 'apk', + '--debug', + ], workingDirectory: tempDir.path); + + expect(result.exitCode, 0); + expect(result.stdout.toString(), contains('app-debug.apk')); + }); + + testWithoutContext('simple build apk without FlutterMultiDexApplication fails', () async { + final MultidexProject project = MultidexProject(false); + await project.setUpIn(tempDir); + final String flutterBin = fileSystem.path.join(getFlutterRoot(), 'bin', 'flutter'); + final ProcessResult result = await processManager.run([ + flutterBin, + ...getLocalEngineArguments(), + 'build', + 'apk', + '--debug', + ], workingDirectory: tempDir.path); + + expect(result.stderr.toString(), contains('Cannot fit requested classes in a single dex file')); + expect(result.stderr.toString(), contains('The number of method references in a .dex file cannot exceed 64K.')); + expect(result.exitCode, 1); + }); +} diff --git a/packages/flutter_tools/test/integration.shard/test_data/multidex_project.dart b/packages/flutter_tools/test/integration.shard/test_data/multidex_project.dart new file mode 100644 index 0000000000..70fdf0d231 --- /dev/null +++ b/packages/flutter_tools/test/integration.shard/test_data/multidex_project.dart @@ -0,0 +1,322 @@ +// 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. + +// @dart = 2.8 + +import 'package:file/file.dart'; + +import '../../src/common.dart'; +import '../test_utils.dart'; +import 'project.dart'; + +class MultidexProject extends Project { + MultidexProject(this.includeFlutterMultiDexApplication); + + @override + Future setUpIn(Directory dir, { + bool useDeferredLoading = false, + bool useSyntheticPackage = false, + }) { + this.dir = dir; + if (androidSettings != null) { + writeFile(fileSystem.path.join(dir.path, 'android', 'settings.gradle'), androidSettings); + } + if (androidBuild != null) { + writeFile(fileSystem.path.join(dir.path, 'android', 'build.gradle'), androidBuild); + } + if (androidLocalProperties != null) { + writeFile(fileSystem.path.join(dir.path, 'android', 'local.properties'), androidLocalProperties); + } + if (androidGradleProperties != null) { + writeFile(fileSystem.path.join(dir.path, 'android', 'gradle.properties'), androidGradleProperties); + } + if (appBuild != null) { + writeFile(fileSystem.path.join(dir.path, 'android', 'app', 'build.gradle'), appBuild); + } + if (appManifest != null) { + writeFile(fileSystem.path.join(dir.path, 'android', 'app', 'src', 'main', 'AndroidManifest.xml'), appManifest); + } + if (appStrings != null) { + writeFile(fileSystem.path.join(dir.path, 'android', 'app', 'src', 'main', 'res', 'values', 'strings.xml'), appStrings); + } + if (appStyles != null) { + writeFile(fileSystem.path.join(dir.path, 'android', 'app', 'src', 'main', 'res', 'values', 'styles.xml'), appStyles); + } + if (appLaunchBackground != null) { + writeFile(fileSystem.path.join(dir.path, 'android', 'app', 'src', 'main', 'res', 'drawable', 'launch_background.xml'), appLaunchBackground); + } + if (includeFlutterMultiDexApplication && appMultidexApplication != null) { + writeFile(fileSystem.path.join(dir.path, 'android', 'app', 'src', 'main', 'java', fileSystem.path.join('io', 'flutter', 'app', 'FlutterMultiDexApplication.java')), appMultidexApplication); + } + return super.setUpIn(dir); + } + + final bool includeFlutterMultiDexApplication; + + @override + final String pubspec = ''' + name: test + environment: + sdk: ">=2.12.0-0 <3.0.0" + + dependencies: + flutter: + sdk: flutter + cloud_firestore: ^2.5.3 + firebase_core: ^1.6.0 + '''; + + @override + final String main = r''' + import 'package:flutter/material.dart'; + + void main() { + runApp(MyApp()); + } + + class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return new MaterialApp( + title: 'Flutter Demo', + home: new Container(), + ); + } + } + '''; + + String get androidSettings => r''' + include ':app' + + def localPropertiesFile = new File(rootProject.projectDir, "local.properties") + def properties = new Properties() + + assert localPropertiesFile.exists() + localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" + '''; + + String get androidBuild => r''' + buildscript { + ext.kotlin_version = '1.3.50' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:4.1.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } + } + + allprojects { + repositories { + google() + mavenCentral() + } + } + + rootProject.buildDir = '../build' + subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" + } + subprojects { + project.evaluationDependsOn(':app') + } + + task clean(type: Delete) { + delete rootProject.buildDir + } + '''; + + String get appBuild => r''' + def localProperties = new Properties() + def localPropertiesFile = rootProject.file('local.properties') + if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } + } + + def flutterRoot = localProperties.getProperty('flutter.sdk') + if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") + } + + def flutterVersionCode = localProperties.getProperty('flutter.versionCode') + if (flutterVersionCode == null) { + flutterVersionCode = '1' + } + + def flutterVersionName = localProperties.getProperty('flutter.versionName') + if (flutterVersionName == null) { + flutterVersionName = '1.0' + } + + apply plugin: 'com.android.application' + apply plugin: 'kotlin-android' + apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + + android { + compileSdkVersion 30 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "com.example.multidextest2" + minSdkVersion 16 + targetSdkVersion 30 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } + } + + flutter { + source '../..' + } + + dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + } + '''; + + String get androidLocalProperties => ''' + flutter.sdk=${getFlutterRoot()} + flutter.buildMode=debug + flutter.versionName=1.0.0 + flutter.versionCode=22 + '''; + + String get androidGradleProperties => ''' + org.gradle.jvmargs=-Xmx1536M + android.useAndroidX=true + android.enableJetifier=true + android.enableR8=true + android.experimental.enableNewResourceShrinker=true + '''; + + String get appManifest => r''' + + + + + + + + + + + + + + + '''; + + String get appStrings => r''' + + + + '''; + + String get appStyles => r''' + + + + + + + + '''; + + String get appLaunchBackground => r''' + + + + + + + + + '''; + + String get appMultidexApplication => r''' + // Generated file. + // If you wish to remove Flutter's multidex support, delete this entire file. + + package io.flutter.app; + + import android.content.Context; + import androidx.annotation.CallSuper; + import androidx.multidex.MultiDex; + + /** + * Extension of {@link io.flutter.app.FlutterApplication}, adding multidex support. + */ + public class FlutterMultiDexApplication extends FlutterApplication { + @Override + @CallSuper + protected void attachBaseContext(Context base) { + super.attachBaseContext(base); + MultiDex.install(this); + } + } + '''; +}