blob: ee1804fc142029c81b7ac3772449e49c451320e0 [file] [log] [blame]
// Copyright 2013 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:file/file.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:platform/platform.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:pubspec_parse/pubspec_parse.dart';
import 'common/core.dart';
import 'common/file_utils.dart';
import 'common/output_utils.dart';
import 'common/package_command.dart';
import 'common/process_runner.dart';
import 'common/pub_utils.dart';
import 'common/repository_package.dart';
/// The name of the build-all-packages project, as passed to `flutter create`.
@visibleForTesting
const String allPackagesProjectName = 'all_packages';
const int _exitFlutterCreateFailed = 3;
const int _exitGenNativeBuildFilesFailed = 4;
const int _exitMissingFile = 5;
const int _exitMissingLegacySource = 6;
/// A command to create an application that builds all in a single application.
class CreateAllPackagesAppCommand extends PackageCommand {
/// Creates an instance of the builder command.
CreateAllPackagesAppCommand(
Directory packagesDir, {
ProcessRunner processRunner = const ProcessRunner(),
Platform platform = const LocalPlatform(),
}) : super(packagesDir, processRunner: processRunner, platform: platform) {
argParser.addOption(_outputDirectoryFlag,
defaultsTo: packagesDir.parent.path,
help: 'The path the directory to create the "$allPackagesProjectName" '
'project in.\n'
'Defaults to the repository root.');
argParser.addOption(_legacySourceFlag,
help: 'A partial project directory to use as a source for replacing '
'portions of the created app. All top-level directories in the '
'source will replace the corresponding directories in the output '
'directory post-create.\n\n'
'The replacement will be done before any tool-driven '
'modifications.');
}
static const String _legacySourceFlag = 'legacy-source';
static const String _outputDirectoryFlag = 'output-dir';
/// The location to create the synthesized app project.
Directory get _appDirectory => packagesDir.fileSystem
.directory(getStringArg(_outputDirectoryFlag))
.childDirectory(allPackagesProjectName);
/// The synthesized app project.
RepositoryPackage get app => RepositoryPackage(_appDirectory);
@override
String get description =>
'Generate Flutter app that includes all target packagas.';
@override
String get name => 'create-all-packages-app';
@override
Future<void> run() async {
final int exitCode = await _createApp();
if (exitCode != 0) {
printError('Failed to `flutter create`: $exitCode');
throw ToolExit(_exitFlutterCreateFailed);
}
final String? legacySource = getNullableStringArg(_legacySourceFlag);
if (legacySource != null) {
final Directory legacyDir =
packagesDir.fileSystem.directory(legacySource);
await _replaceWithLegacy(target: _appDirectory, source: legacyDir);
}
final Set<String> excluded = getExcludedPackageNames();
if (excluded.isNotEmpty) {
print('Exluding the following plugins from the combined build:');
for (final String plugin in excluded) {
print(' $plugin');
}
print('');
}
await _genPubspecWithAllPlugins();
// Run `flutter pub get` to generate all native build files.
// TODO(stuartmorgan): This hangs on Windows for some reason. Since it's
// currently not needed on Windows, skip it there, but we should investigate
// further and/or implement https://github.com/flutter/flutter/issues/93407,
// and remove the need for this conditional.
if (!platform.isWindows) {
if (!await runPubGet(app, processRunner, platform)) {
printError(
"Failed to generate native build files via 'flutter pub get'");
throw ToolExit(_exitGenNativeBuildFilesFailed);
}
}
await Future.wait(<Future<void>>[
_updateAppGradle(),
_updateIOSPbxproj(),
_updateMacOSPbxproj(),
// This step requires the native file generation triggered by
// flutter pub get above, so can't currently be run on Windows.
if (!platform.isWindows) _updateMacosPodfile(),
]);
}
Future<int> _createApp() async {
return processRunner.runAndStream(
flutterCommand,
<String>[
'create',
'--template=app',
'--project-name=$allPackagesProjectName',
_appDirectory.path,
],
);
}
Future<void> _replaceWithLegacy(
{required Directory target, required Directory source}) async {
if (!source.existsSync()) {
printError('No such legacy source directory: ${source.path}');
throw ToolExit(_exitMissingLegacySource);
}
for (final FileSystemEntity entity in source.listSync()) {
final String basename = entity.basename;
print('Replacing $basename with legacy version...');
if (entity is Directory) {
target.childDirectory(basename).deleteSync(recursive: true);
} else {
target.childFile(basename).deleteSync();
}
_copyDirectory(source: source, target: target);
}
}
void _copyDirectory({required Directory target, required Directory source}) {
target.createSync(recursive: true);
for (final FileSystemEntity entity in source.listSync(recursive: true)) {
final List<String> subcomponents =
p.split(p.relative(entity.path, from: source.path));
if (entity is Directory) {
childDirectoryWithSubcomponents(target, subcomponents)
.createSync(recursive: true);
} else if (entity is File) {
final File targetFile =
childFileWithSubcomponents(target, subcomponents);
targetFile.parent.createSync(recursive: true);
entity.copySync(targetFile.path);
} else {
throw UnimplementedError('Unsupported entity: $entity');
}
}
}
/// Rewrites [file], replacing any lines contain a key in [replacements] with
/// the lines in the corresponding value, and adding any lines in [additions]'
/// values after lines containing the key.
void _adjustFile(
File file, {
Map<String, List<String>> replacements = const <String, List<String>>{},
Map<String, List<String>> additions = const <String, List<String>>{},
Map<RegExp, List<String>> regexReplacements =
const <RegExp, List<String>>{},
}) {
if (replacements.isEmpty && additions.isEmpty) {
return;
}
if (!file.existsSync()) {
printError('Unable to find ${file.path} for updating.');
throw ToolExit(_exitMissingFile);
}
final StringBuffer output = StringBuffer();
for (final String line in file.readAsLinesSync()) {
List<String>? replacementLines;
for (final MapEntry<String, List<String>> replacement
in replacements.entries) {
if (line.contains(replacement.key)) {
replacementLines = replacement.value;
break;
}
}
if (replacementLines == null) {
for (final MapEntry<RegExp, List<String>> replacement
in regexReplacements.entries) {
final RegExpMatch? match = replacement.key.firstMatch(line);
if (match != null) {
replacementLines = replacement.value;
break;
}
}
}
(replacementLines ?? <String>[line]).forEach(output.writeln);
for (final String targetString in additions.keys) {
if (line.contains(targetString)) {
additions[targetString]!.forEach(output.writeln);
}
}
}
file.writeAsStringSync(output.toString());
}
Future<void> _updateAppGradle() async {
final File gradleFile = app
.platformDirectory(FlutterPlatform.android)
.childDirectory('app')
.childFile('build.gradle');
// Ensure that there is a dependencies section, so the dependencies addition
// below will work.
final String content = gradleFile.readAsStringSync();
if (!content.contains('\ndependencies {')) {
gradleFile.writeAsStringSync('''
$content
dependencies {}
''');
}
const String lifecycleDependency =
" implementation 'androidx.lifecycle:lifecycle-runtime:2.2.0-rc01'";
_adjustFile(
gradleFile,
replacements: <String, List<String>>{
// minSdkVersion 21 is required by camera_android.
'minSdkVersion': <String>['minSdkVersion 21'],
'compileSdkVersion': <String>['compileSdk 34'],
},
additions: <String, List<String>>{
'defaultConfig {': <String>[' multiDexEnabled true'],
},
regexReplacements: <RegExp, List<String>>{
// Tests for https://github.com/flutter/flutter/issues/43383
// Handling of 'dependencies' is more complex since it hasn't been very
// stable across template versions.
// - Handle an empty, collapsed dependencies section.
RegExp(r'^dependencies\s+{\s*}$'): <String>[
'dependencies {',
lifecycleDependency,
'}',
],
// - Handle a normal dependencies section.
RegExp(r'^dependencies\s+{$'): <String>[
'dependencies {',
lifecycleDependency,
],
// - See below for handling of the case where there is no dependencies
// section.
},
);
}
Future<void> _genPubspecWithAllPlugins() async {
// Read the old pubspec file's Dart SDK version, in order to preserve it
// in the new file. The template sometimes relies on having opted in to
// specific language features via SDK version, so using a different one
// can cause compilation failures.
final Pubspec originalPubspec = app.parsePubspec();
const String dartSdkKey = 'sdk';
final VersionConstraint dartSdkConstraint =
originalPubspec.environment?[dartSdkKey] ??
VersionConstraint.compatibleWith(
Version.parse('2.12.0'),
);
final Map<String, PathDependency> pluginDeps =
await _getValidPathDependencies();
final Pubspec pubspec = Pubspec(
allPackagesProjectName,
description: 'Flutter app containing all 1st party plugins.',
version: Version.parse('1.0.0+1'),
environment: <String, VersionConstraint>{
dartSdkKey: dartSdkConstraint,
},
dependencies: <String, Dependency>{
'flutter': SdkDependency('flutter'),
}..addAll(pluginDeps),
devDependencies: <String, Dependency>{
'flutter_test': SdkDependency('flutter'),
},
dependencyOverrides: pluginDeps,
);
// An application cannot depend directly on multiple federated
// implementations of the same plugin for the same platform, which means the
// app cannot directly depend on both camera_android and
// camera_android_androidx. Since camera_android is endorsed, it will be
// included transitively already, so exclude it from the direct dependency
// list to allow including camera_android_androidx to ensure that they don't
// conflict at build time (if they did, it would be impossible to use
// camera_android_androidx while camera_android is endorsed).
// This is special-cased here, rather than being done via the normal
// exclusion config file mechanism, because it still needs to be in the
// depenedency overrides list to ensure that the version from path is used.
pubspec.dependencies.remove('camera_android');
app.pubspecFile.writeAsStringSync(_pubspecToString(pubspec));
}
Future<Map<String, PathDependency>> _getValidPathDependencies() async {
final Map<String, PathDependency> pathDependencies =
<String, PathDependency>{};
await for (final PackageEnumerationEntry entry in getTargetPackages()) {
final RepositoryPackage package = entry.package;
final Directory pluginDirectory = package.directory;
final String pluginName = pluginDirectory.basename;
final Pubspec pubspec = package.parsePubspec();
if (pubspec.publishTo != 'none') {
pathDependencies[pluginName] = PathDependency(pluginDirectory.path);
}
}
return pathDependencies;
}
String _pubspecToString(Pubspec pubspec) {
return '''
### Generated file. Do not edit. Run `dart pub global run flutter_plugin_tools gen-pubspec` to update.
name: ${pubspec.name}
description: ${pubspec.description}
publish_to: none
version: ${pubspec.version}
environment:${_pubspecMapString(pubspec.environment!)}
dependencies:${_pubspecMapString(pubspec.dependencies)}
dependency_overrides:${_pubspecMapString(pubspec.dependencyOverrides)}
dev_dependencies:${_pubspecMapString(pubspec.devDependencies)}
###''';
}
String _pubspecMapString(Map<String, Object?> values) {
final StringBuffer buffer = StringBuffer();
for (final MapEntry<String, Object?> entry in values.entries) {
buffer.writeln();
final Object? entryValue = entry.value;
if (entryValue is VersionConstraint) {
String value = entryValue.toString();
// Range constraints require quoting.
if (value.startsWith('>') || value.startsWith('<')) {
value = "'$value'";
}
buffer.write(' ${entry.key}: $value');
} else if (entryValue is SdkDependency) {
buffer.write(' ${entry.key}: \n sdk: ${entryValue.sdk}');
} else if (entryValue is PathDependency) {
String depPath = entryValue.path;
if (path.style == p.Style.windows) {
// Posix-style path separators are preferred in pubspec.yaml (and
// using a consistent format makes unit testing simpler), so convert.
final List<String> components = path.split(depPath);
final String firstComponent = components.first;
// path.split leaves a \ on drive components that isn't necessary,
// and confuses pub, so remove it.
if (firstComponent.endsWith(r':\')) {
components[0] =
firstComponent.substring(0, firstComponent.length - 1);
}
depPath = p.posix.joinAll(components);
}
buffer.write(' ${entry.key}: \n path: $depPath');
} else {
throw UnimplementedError(
'Not available for type: ${entryValue.runtimeType}',
);
}
}
return buffer.toString();
}
Future<void> _updateMacosPodfile() async {
/// Only change the macOS deployment target if the host platform is macOS.
/// The Podfile is not generated on other platforms.
if (!platform.isMacOS) {
return;
}
final File podfile =
app.platformDirectory(FlutterPlatform.macos).childFile('Podfile');
_adjustFile(
podfile,
replacements: <String, List<String>>{
// macOS 10.15 is required by in_app_purchase.
'platform :osx': <String>["platform :osx, '10.15'"],
},
);
}
Future<void> _updateMacOSPbxproj() async {
final File pbxprojFile = app
.platformDirectory(FlutterPlatform.macos)
.childDirectory('Runner.xcodeproj')
.childFile('project.pbxproj');
_adjustFile(
pbxprojFile,
replacements: <String, List<String>>{
// macOS 10.15 is required by in_app_purchase.
'MACOSX_DEPLOYMENT_TARGET': <String>[
' MACOSX_DEPLOYMENT_TARGET = 10.15;'
],
},
);
}
Future<void> _updateIOSPbxproj() async {
final File pbxprojFile = app
.platformDirectory(FlutterPlatform.ios)
.childDirectory('Runner.xcodeproj')
.childFile('project.pbxproj');
_adjustFile(
pbxprojFile,
replacements: <String, List<String>>{
// iOS 14 is required by google_maps_flutter.
'IPHONEOS_DEPLOYMENT_TARGET': <String>[
' IPHONEOS_DEPLOYMENT_TARGET = 14.0;'
],
},
);
}
}