[ci] Add a legacy Android build-all test (#4005)

Adds the ability to replace portions of the `flutter create`d app with saved copies, and adds a second build-all phase for Android that uses a Flutter 2.0.6-created `android/` directory (AGP 4.1, Gradle 6.7) to catch issues like https://github.com/flutter/flutter/issues/125621 and https://github.com/flutter/flutter/issues/125482 prior to release.

Includes some incidental cleanup:
- Extracts a helper method for adjusting files, so that this doesn't add even more copies of basically identical code.
    - (This was motivated by an earlier version of the PR that added modifications to several more files, which I ended up undoing, but the cleanup seemed worth keeping.)
- Adds missing unit test coverage.
- Reworks the unit tests to use a mock process manager and dummy files, instead of each test actually calling `flutter create`, which made each new test add several seconds to the unit test suite.
    - While this reduces the integration-style coverage, in practice the integration tests of the repo tooling is the CI itself, so the unit tests should be true unit tests.
- Changes the non-legacy test to Kotlin; we were still testing with Java even though Kotlin has been the default for quite a while, so we weren't testing what most new users would actually be running. Since we now have a legacy test, I used Java there to cover both.
- Removes some dead code for modifying the AndroidManifest.xml; when trying to set up unit tests for it I discovered that it no longer matches anything in an actual project. It dates back to the original command, and seems to have been a camera-related hack, which we clearly no longer need since it wasn't working and camera still works in build-all.

This is captured somewhat in the README in the legacy project directory, but to document the approach here: originally I was going to add flags to change individual items (AGP version, Gradle version), but quickly ran into the fact that selective downgrading is extremely fragile. E.g.,:
- The Kotlin version set in current projects doesn't work when downgrading AGP and Gradle.
- The app template can unconditionally use anything (e.g., `namespace`) that the AGP version it uses supports, so arbitrary future breakage is possible.

It's also less useful as a real test of what a plugin client's project likely looks like. Starting with a complete platform directory, and doing whatever the minimal changes are to keep it working, will likely reflect a common real-world scenario. On the flip side, the reason this doesn't use a complete 2.0.6 project, but instead is based on specific platform directories, is that we don't want to waste time manually maintaining, e.g., old Dart code that is irrelevant to the goal of the test. For now this only uses Android because that's where we've seen problems in practice, but we can alway add other legacy platform tests later if we find a need.

Fixes https://github.com/flutter/flutter/issues/125689
This commit is contained in:
stuartmorgan
2023-05-22 13:10:38 -07:00
committed by GitHub
parent 44d1fcd659
commit 30ebcf38c9
27 changed files with 888 additions and 165 deletions

View File

@ -0,0 +1,37 @@
This directory contains a partial snapshot of an old Flutter project; it is
intended to replace the corresponding parts of a newly Flutter-created project
to allow testing plugin builds with a legacy project.
It was originally created with Flutter 2.0.6. In general the guidelines are:
- Pieces here should be largely self-contained rather than portions of
major project components; for instance, it currently contains the entire
`android/` directory from a legacy project, rather than a subset of it
which would be combined with a subset of a new project's `android/`
directory. This is to avoid random breakage in the future due to
conflicts between those subsets. For instance, we could probably get
away with not including android/app/src/main/res for a while, and
instead layer in the versions from a new project, but then someday
if the resources were renamed, there would be dangling references to
the old resources in files that are included here.
- Updates over time should be minimal. We don't expect that an unchanged
project will keep working forever, but this directory should simulate
a developer who has done the bare minimum to keep their project working
as they have updated Flutter.
- Updates should be logged below.
The reason for the hybrid model, rather than checking in a full legacy
project, is to minimize unnecessary maintenance work. E.g., there's no
need to manually keep Dart code updated for Flutter changes just to
test legacy native Android build behaviors.
## Manual changes to files
The following are the changes relative to running:
```bash
flutter create -a java all_packages
```
and then deleting everything but `android/` from it:
- Added license boilerplate.

View File

@ -0,0 +1,46 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/
# Web related
lib/generated_plugin_registrant.dart
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

View File

@ -0,0 +1,10 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: 1d9032c7e1d867f071f2277eb1673e8f9b0274e3
channel: unknown
project_type: app

View File

@ -0,0 +1,11 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
# Remember to never publicly share your keystore.
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
key.properties

View File

@ -0,0 +1,47 @@
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 from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
android {
compileSdkVersion 30
defaultConfig {
applicationId "com.example.all_packages"
minSdkVersion 16
targetSdkVersion 30
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
buildTypes {
release {
signingConfig signingConfigs.debug
}
}
}
flutter {
source '../..'
}

View File

@ -0,0 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.all_packages">
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,41 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.all_packages">
<application
android:label="all_packages"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<!-- Displays an Android View that continues showing the launch screen
Drawable until Flutter paints its first frame, then this splash
screen fades out. A splash screen is useful to avoid any visual
gap between the end of Android's launch screen and the painting of
Flutter's first frame. -->
<meta-data
android:name="io.flutter.embedding.android.SplashScreenDrawable"
android:resource="@drawable/launch_background"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>

View File

@ -0,0 +1,10 @@
// 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.
package com.example.all_packages;
import io.flutter.embedding.android.FlutterActivity;
public class MainActivity extends FlutterActivity {
}

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.all_packages">
<!-- Flutter needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@ -0,0 +1,29 @@
buildscript {
repositories {
google()
jcenter()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.1.0'
}
}
allprojects {
repositories {
google()
jcenter()
}
}
rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(':app')
}
task clean(type: Delete) {
delete rootProject.buildDir
}

View File

@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx1536M
android.useAndroidX=true
android.enableJetifier=true

View File

@ -0,0 +1,6 @@
#Fri Jun 23 08:50:38 CEST 2017
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip

View File

@ -0,0 +1,11 @@
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"

View File

@ -302,6 +302,11 @@ task:
CHANNEL: "master"
CHANNEL: "stable"
<< : *BUILD_ALL_PACKAGES_APP_TEMPLATE
create_all_packages_app_legacy_script:
- $PLUGIN_TOOL_COMMAND create-all-packages-app --legacy-source=.ci/legacy_project/all_packages --output-dir=legacy/ --exclude script/configs/exclude_all_packages_app.yaml
build_all_packages_legacy_script:
- cd legacy/all_packages
- flutter build $BUILD_ALL_ARGS --debug
### Web tasks ###
- name: web-platform_tests
env:

View File

@ -11,10 +11,21 @@ import 'package:file/file.dart';
/// childFileWithSubcomponents(rootDir, ['foo', 'bar', 'baz.txt'])
/// creates a File representing /rootDir/foo/bar/baz.txt.
File childFileWithSubcomponents(Directory base, List<String> components) {
Directory dir = base;
final String basename = components.removeLast();
return childDirectoryWithSubcomponents(base, components).childFile(basename);
}
/// Returns a [Directory] created by appending everything in [components]
/// to [base] as subdirectories.
///
/// Example:
/// childFileWithSubcomponents(rootDir, ['foo', 'bar'])
/// creates a File representing /rootDir/foo/bar/.
Directory childDirectoryWithSubcomponents(
Directory base, List<String> components) {
Directory dir = base;
for (final String directoryName in components) {
dir = dir.childDirectory(directoryName);
}
return dir.childFile(basename);
return dir;
}

View File

@ -2,26 +2,27 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:io' as io;
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/package_command.dart';
import 'common/process_runner.dart';
import 'common/repository_package.dart';
const String _outputDirectoryFlag = 'output-dir';
/// The name of the build-all-packages project, as passed to `flutter create`.
@visibleForTesting
const String allPackagesProjectName = 'all_packages';
const String _projectName = 'all_packages';
const int _exitUpdateMacosPodfileFailed = 3;
const int _exitUpdateMacosPbxprojFailed = 4;
const int _exitGenNativeBuildFilesFailed = 5;
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 {
@ -29,22 +30,29 @@ class CreateAllPackagesAppCommand extends PackageCommand {
CreateAllPackagesAppCommand(
Directory packagesDir, {
ProcessRunner processRunner = const ProcessRunner(),
Directory? pluginsRoot,
Platform platform = const LocalPlatform(),
}) : super(packagesDir, processRunner: processRunner, platform: platform) {
final Directory defaultDir =
pluginsRoot ?? packagesDir.fileSystem.currentDirectory;
argParser.addOption(_outputDirectoryFlag,
defaultsTo: defaultDir.path,
help:
'The path the directory to create the "$_projectName" project in.\n'
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(_projectName);
.childDirectory(allPackagesProjectName);
/// The synthesized app project.
RepositoryPackage get app => RepositoryPackage(_appDirectory);
@ -60,7 +68,15 @@ class CreateAllPackagesAppCommand extends PackageCommand {
Future<void> run() async {
final int exitCode = await _createApp();
if (exitCode != 0) {
throw ToolExit(exitCode);
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();
@ -89,7 +105,6 @@ class CreateAllPackagesAppCommand extends PackageCommand {
await Future.wait(<Future<void>>[
_updateAppGradle(),
_updateManifest(),
_updateMacosPbxproj(),
// This step requires the native file generation triggered by
// flutter pub get above, so can't currently be run on Windows.
@ -98,20 +113,101 @@ class CreateAllPackagesAppCommand extends PackageCommand {
}
Future<int> _createApp() async {
final io.ProcessResult result = io.Process.runSync(
return processRunner.runAndStream(
flutterCommand,
<String>[
'create',
'--template=app',
'--project-name=$_projectName',
'--android-language=java',
'--project-name=$allPackagesProjectName',
_appDirectory.path,
],
);
}
print(result.stdout);
print(result.stderr);
return result.exitCode;
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 {
@ -119,60 +215,51 @@ class CreateAllPackagesAppCommand extends PackageCommand {
.platformDirectory(FlutterPlatform.android)
.childDirectory('app')
.childFile('build.gradle');
if (!gradleFile.existsSync()) {
throw ToolExit(64);
// 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 {}
''');
}
final StringBuffer newGradle = StringBuffer();
for (final String line in gradleFile.readAsLinesSync()) {
if (line.contains('minSdkVersion')) {
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.
newGradle.writeln('minSdkVersion 21');
} else if (line.contains('compileSdkVersion')) {
'minSdkVersion': <String>['minSdkVersion 21'],
// compileSdkVersion 33 is required by local_auth.
newGradle.writeln('compileSdkVersion 33');
} else {
newGradle.writeln(line);
}
if (line.contains('defaultConfig {')) {
newGradle.writeln(' multiDexEnabled true');
} else if (line.contains('dependencies {')) {
'compileSdkVersion': <String>['compileSdkVersion 33'],
},
additions: <String, List<String>>{
'defaultConfig {': <String>[' multiDexEnabled true'],
},
regexReplacements: <RegExp, List<String>>{
// Tests for https://github.com/flutter/flutter/issues/43383
newGradle.writeln(
" implementation 'androidx.lifecycle:lifecycle-runtime:2.2.0-rc01'\n",
// 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.
},
);
}
}
gradleFile.writeAsStringSync(newGradle.toString());
}
Future<void> _updateManifest() async {
final File manifestFile = app
.platformDirectory(FlutterPlatform.android)
.childDirectory('app')
.childDirectory('src')
.childDirectory('main')
.childFile('AndroidManifest.xml');
if (!manifestFile.existsSync()) {
throw ToolExit(64);
}
final StringBuffer newManifest = StringBuffer();
for (final String line in manifestFile.readAsLinesSync()) {
if (line.contains('package="com.example.$_projectName"')) {
newManifest
..writeln('package="com.example.$_projectName"')
..writeln('xmlns:tools="http://schemas.android.com/tools">')
..writeln()
..writeln(
'<uses-sdk tools:overrideLibrary="io.flutter.plugins.camera"/>',
);
} else {
newManifest.writeln(line);
}
}
manifestFile.writeAsStringSync(newManifest.toString());
}
Future<void> _genPubspecWithAllPlugins() async {
// Read the old pubspec file's Dart SDK version, in order to preserve it
@ -190,7 +277,7 @@ class CreateAllPackagesAppCommand extends PackageCommand {
final Map<String, PathDependency> pluginDeps =
await _getValidPathDependencies();
final Pubspec pubspec = Pubspec(
_projectName,
allPackagesProjectName,
description: 'Flutter app containing all 1st party plugins.',
version: Version.parse('1.0.0+1'),
environment: <String, VersionConstraint>{
@ -300,23 +387,15 @@ dev_dependencies:${_pubspecMapString(pubspec.devDependencies)}
return;
}
final File podfileFile =
final File podfile =
app.platformDirectory(FlutterPlatform.macos).childFile('Podfile');
if (!podfileFile.existsSync()) {
printError("Can't find Podfile for macOS");
throw ToolExit(_exitUpdateMacosPodfileFailed);
}
final StringBuffer newPodfile = StringBuffer();
for (final String line in podfileFile.readAsLinesSync()) {
if (line.contains('platform :osx')) {
_adjustFile(
podfile,
replacements: <String, List<String>>{
// macOS 10.15 is required by in_app_purchase.
newPodfile.writeln("platform :osx, '10.15'");
} else {
newPodfile.writeln(line);
}
}
podfileFile.writeAsStringSync(newPodfile.toString());
'platform :osx': <String>["platform :osx, '10.15'"],
},
);
}
Future<void> _updateMacosPbxproj() async {
@ -324,20 +403,14 @@ dev_dependencies:${_pubspecMapString(pubspec.devDependencies)}
.platformDirectory(FlutterPlatform.macos)
.childDirectory('Runner.xcodeproj')
.childFile('project.pbxproj');
if (!pbxprojFile.existsSync()) {
printError("Can't find project.pbxproj for macOS");
throw ToolExit(_exitUpdateMacosPbxprojFailed);
}
final StringBuffer newPbxproj = StringBuffer();
for (final String line in pbxprojFile.readAsLinesSync()) {
if (line.contains('MACOSX_DEPLOYMENT_TARGET')) {
_adjustFile(
pbxprojFile,
replacements: <String, List<String>>{
// macOS 10.15 is required by in_app_purchase.
newPbxproj.writeln(' MACOSX_DEPLOYMENT_TARGET = 10.15;');
} else {
newPbxproj.writeln(line);
}
}
pbxprojFile.writeAsStringSync(newPbxproj.toString());
'MACOSX_DEPLOYMENT_TARGET': <String>[
' MACOSX_DEPLOYMENT_TARGET = 10.15;'
],
},
);
}
}

View File

@ -8,9 +8,9 @@ import 'package:flutter_plugin_tools/src/common/file_utils.dart';
import 'package:test/test.dart';
void main() {
group('childFileWithSubcomponents', () {
test('works on Posix', () async {
final FileSystem fileSystem =
MemoryFileSystem();
final FileSystem fileSystem = MemoryFileSystem();
final Directory base = fileSystem.directory('/').childDirectory('base');
final File file =
@ -23,10 +23,36 @@ void main() {
final FileSystem fileSystem =
MemoryFileSystem(style: FileSystemStyle.windows);
final Directory base = fileSystem.directory(r'C:\').childDirectory('base');
final Directory base =
fileSystem.directory(r'C:\').childDirectory('base');
final File file =
childFileWithSubcomponents(base, <String>['foo', 'bar', 'baz.txt']);
expect(file.absolute.path, r'C:\base\foo\bar\baz.txt');
});
});
group('childDirectoryWithSubcomponents', () {
test('works on Posix', () async {
final FileSystem fileSystem = MemoryFileSystem();
final Directory base = fileSystem.directory('/').childDirectory('base');
final Directory dir =
childDirectoryWithSubcomponents(base, <String>['foo', 'bar', 'baz']);
expect(dir.absolute.path, '/base/foo/bar/baz');
});
test('works on Windows', () async {
final FileSystem fileSystem =
MemoryFileSystem(style: FileSystemStyle.windows);
final Directory base =
fileSystem.directory(r'C:\').childDirectory('base');
final Directory dir =
childDirectoryWithSubcomponents(base, <String>['foo', 'bar', 'baz']);
expect(dir.absolute.path, r'C:\base\foo\bar\baz');
});
});
}

View File

@ -6,7 +6,7 @@ import 'dart:io' as io;
import 'package:args/command_runner.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:file/memory.dart';
import 'package:flutter_plugin_tools/src/common/core.dart';
import 'package:flutter_plugin_tools/src/create_all_packages_app_command.dart';
import 'package:platform/platform.dart';
@ -18,16 +18,15 @@ import 'util.dart';
void main() {
late CommandRunner<void> runner;
late CreateAllPackagesAppCommand command;
late Platform mockPlatform;
late FileSystem fileSystem;
late Directory testRoot;
late Directory packagesDir;
late RecordingProcessRunner processRunner;
setUp(() {
// Since the core of this command is a call to 'flutter create', the test
// has to use the real filesystem. Put everything possible in a unique
// temporary to minimize effect on the host system.
fileSystem = const LocalFileSystem();
mockPlatform = MockPlatform(isMacOS: true);
fileSystem = MemoryFileSystem();
testRoot = fileSystem.systemTempDirectory.createTempSync();
packagesDir = testRoot.childDirectory('packages');
processRunner = RecordingProcessRunner();
@ -35,34 +34,142 @@ void main() {
command = CreateAllPackagesAppCommand(
packagesDir,
processRunner: processRunner,
pluginsRoot: testRoot,
platform: mockPlatform,
);
runner = CommandRunner<void>(
'create_all_test', 'Test for $CreateAllPackagesAppCommand');
runner.addCommand(command);
});
tearDown(() {
testRoot.deleteSync(recursive: true);
});
/// Simulates enough of `flutter create`s output to allow the modifications
/// made by the command to work.
void writeFakeFlutterCreateOutput(
Directory outputDirectory, {
String dartSdkConstraint = '>=3.0.0 <4.0.0',
String? appBuildGradleDependencies,
bool androidOnly = false,
}) {
final RepositoryPackage package = RepositoryPackage(
outputDirectory.childDirectory(allPackagesProjectName));
// Android
final String dependencies = appBuildGradleDependencies ??
r'''
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}
''';
package
.platformDirectory(FlutterPlatform.android)
.childDirectory('app')
.childFile('build.gradle')
..createSync(recursive: true)
..writeAsStringSync('''
android {
namespace 'dev.flutter.packages.foo.example'
compileSdkVersion flutter.compileSdkVersion
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
applicationId "dev.flutter.packages.foo.example"
minSdkVersion flutter.minSdkVersion
targetSdkVersion 32
}
}
$dependencies
''');
if (androidOnly) {
return;
}
// Non-platform-specific
package.pubspecFile
..createSync(recursive: true)
..writeAsStringSync('''
name: $allPackagesProjectName
description: Flutter app containing all 1st party plugins.
publish_to: none
version: 1.0.0
environment:
sdk: '$dartSdkConstraint'
dependencies:
flutter:
sdk: flutter
dev_dependencies:
flutter_test:
sdk: flutter
###
''');
// macOS
final Directory macOS = package.platformDirectory(FlutterPlatform.macos);
macOS.childDirectory('Runner.xcodeproj').childFile('project.pbxproj')
..createSync(recursive: true)
..writeAsStringSync('''
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 10.14;
};
name = Release;
};
''');
macOS.childFile('Podfile')
..createSync(recursive: true)
..writeAsStringSync('''
# platform :osx, '10.14'
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
''');
}
group('non-macOS host', () {
setUp(() {
mockPlatform = MockPlatform(isLinux: true);
command = CreateAllPackagesAppCommand(
packagesDir,
processRunner: processRunner,
// Set isWindows or not based on the actual host, so that
// `flutterCommand` works, since these tests actually call 'flutter'.
// The important thing is that isMacOS always returns false.
platform: MockPlatform(isWindows: const LocalPlatform().isWindows),
pluginsRoot: testRoot,
platform: mockPlatform,
);
runner = CommandRunner<void>(
'create_all_test', 'Test for $CreateAllPackagesAppCommand');
runner.addCommand(command);
});
test('calls "flutter create"', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
await runCapturingPrint(runner, <String>['create-all-packages-app']);
expect(
processRunner.recordedCalls,
contains(ProcessCall(
getFlutterCommand(mockPlatform),
<String>[
'create',
'--template=app',
'--project-name=$allPackagesProjectName',
testRoot.childDirectory(allPackagesProjectName).path,
],
null)));
});
test('pubspec includes all plugins', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
createFakePlugin('pluginb', packagesDir);
createFakePlugin('pluginc', packagesDir);
@ -80,6 +187,7 @@ void main() {
});
test('pubspec has overrides for all plugins', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
createFakePlugin('pluginb', packagesDir);
createFakePlugin('pluginc', packagesDir);
@ -97,33 +205,186 @@ void main() {
]));
});
test('pubspec preserves existing Dart SDK version', () async {
const String baselineProjectName = 'baseline';
final Directory baselineProjectDirectory =
testRoot.childDirectory(baselineProjectName);
io.Process.runSync(
getFlutterCommand(const LocalPlatform()),
<String>[
'create',
'--template=app',
'--project-name=$baselineProjectName',
baselineProjectDirectory.path,
],
);
final Pubspec baselinePubspec =
RepositoryPackage(baselineProjectDirectory).parsePubspec();
test('legacy files are copied when requested', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
// Make a fake legacy source with all the necessary files, replacing one
// of them.
final Directory legacyDir = testRoot.childDirectory('legacy');
final RepositoryPackage legacySource =
RepositoryPackage(legacyDir.childDirectory(allPackagesProjectName));
writeFakeFlutterCreateOutput(legacyDir, androidOnly: true);
const String legacyAppBuildGradleContents = 'Fake legacy content';
final File legacyGradleFile = legacySource
.platformDirectory(FlutterPlatform.android)
.childFile('build.gradle');
legacyGradleFile.writeAsStringSync(legacyAppBuildGradleContents);
await runCapturingPrint(runner, <String>[
'create-all-packages-app',
'--legacy-source=${legacySource.path}',
]);
final File buildGradle = command.app
.platformDirectory(FlutterPlatform.android)
.childFile('build.gradle');
expect(buildGradle.readAsStringSync(), legacyAppBuildGradleContents);
});
test('legacy directory replaces, rather than overlaying', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
final File extraFile =
RepositoryPackage(testRoot.childDirectory(allPackagesProjectName))
.platformDirectory(FlutterPlatform.android)
.childFile('extra_file');
extraFile.createSync(recursive: true);
// Make a fake legacy source with all the necessary files, but not
// including the extra file.
final Directory legacyDir = testRoot.childDirectory('legacy');
final RepositoryPackage legacySource =
RepositoryPackage(legacyDir.childDirectory(allPackagesProjectName));
writeFakeFlutterCreateOutput(legacyDir, androidOnly: true);
await runCapturingPrint(runner, <String>[
'create-all-packages-app',
'--legacy-source=${legacySource.path}',
]);
expect(extraFile.existsSync(), false);
});
test('legacy files are modified as needed by the tool', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
// Make a fake legacy source with all the necessary files, replacing one
// of them.
final Directory legacyDir = testRoot.childDirectory('legacy');
final RepositoryPackage legacySource =
RepositoryPackage(legacyDir.childDirectory(allPackagesProjectName));
writeFakeFlutterCreateOutput(legacyDir, androidOnly: true);
const String legacyAppBuildGradleContents = '''
# This is the legacy file
android {
compileSdkVersion flutter.compileSdkVersion
defaultConfig {
minSdkVersion flutter.minSdkVersion
}
}
''';
final File legacyGradleFile = legacySource
.platformDirectory(FlutterPlatform.android)
.childDirectory('app')
.childFile('build.gradle');
legacyGradleFile.writeAsStringSync(legacyAppBuildGradleContents);
await runCapturingPrint(runner, <String>[
'create-all-packages-app',
'--legacy-source=${legacySource.path}',
]);
final List<String> buildGradle = command.app
.platformDirectory(FlutterPlatform.android)
.childDirectory('app')
.childFile('build.gradle')
.readAsLinesSync();
expect(
buildGradle,
containsAll(<Matcher>[
contains('This is the legacy file'),
contains('minSdkVersion 21'),
contains('compileSdkVersion 33'),
]));
});
test('pubspec preserves existing Dart SDK version', () async {
const String existingSdkConstraint = '>=1.0.0 <99.0.0';
writeFakeFlutterCreateOutput(testRoot,
dartSdkConstraint: existingSdkConstraint);
createFakePlugin('plugina', packagesDir);
await runCapturingPrint(runner, <String>['create-all-packages-app']);
final Pubspec generatedPubspec = command.app.parsePubspec();
const String dartSdkKey = 'sdk';
expect(generatedPubspec.environment?[dartSdkKey],
baselinePubspec.environment?[dartSdkKey]);
expect(generatedPubspec.environment?[dartSdkKey].toString(),
existingSdkConstraint);
});
test('Android app gradle is modified as expected', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
await runCapturingPrint(runner, <String>['create-all-packages-app']);
final List<String> buildGradle = command.app
.platformDirectory(FlutterPlatform.android)
.childDirectory('app')
.childFile('build.gradle')
.readAsLinesSync();
expect(
buildGradle,
containsAll(<Matcher>[
contains('minSdkVersion 21'),
contains('compileSdkVersion 33'),
contains('multiDexEnabled true'),
contains('androidx.lifecycle:lifecycle-runtime'),
]));
});
// The template's app/build.gradle does not always have a dependencies
// section; ensure that the dependency is added if there is not one.
test('Android lifecyle dependency is added with no dependencies', () async {
writeFakeFlutterCreateOutput(testRoot, appBuildGradleDependencies: '');
createFakePlugin('plugina', packagesDir);
await runCapturingPrint(runner, <String>['create-all-packages-app']);
final List<String> buildGradle = command.app
.platformDirectory(FlutterPlatform.android)
.childDirectory('app')
.childFile('build.gradle')
.readAsLinesSync();
expect(
buildGradle,
containsAllInOrder(<Matcher>[
equals('dependencies {'),
contains('androidx.lifecycle:lifecycle-runtime'),
equals('}'),
]));
});
// Some versions of the template's app/build.gradle has an empty
// dependencies section; ensure that the dependency is added in that case.
test('Android lifecyle dependency is added with empty dependencies',
() async {
writeFakeFlutterCreateOutput(testRoot,
appBuildGradleDependencies: 'dependencies {}');
createFakePlugin('plugina', packagesDir);
await runCapturingPrint(runner, <String>['create-all-packages-app']);
final List<String> buildGradle = command.app
.platformDirectory(FlutterPlatform.android)
.childDirectory('app')
.childFile('build.gradle')
.readAsLinesSync();
expect(
buildGradle,
containsAllInOrder(<Matcher>[
equals('dependencies {'),
contains('androidx.lifecycle:lifecycle-runtime'),
equals('}'),
]));
});
test('macOS deployment target is modified in pbxproj', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
await runCapturingPrint(runner, <String>['create-all-packages-app']);
@ -141,27 +402,50 @@ void main() {
});
test('calls flutter pub get', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
await runCapturingPrint(runner, <String>['create-all-packages-app']);
expect(
processRunner.recordedCalls,
orderedEquals(<ProcessCall>[
ProcessCall(
getFlutterCommand(const LocalPlatform()),
contains(ProcessCall(
getFlutterCommand(mockPlatform),
const <String>['pub', 'get'],
testRoot.childDirectory('all_packages').path),
]));
},
// See comment about Windows in create_all_packages_app_command.dart
skip: io.Platform.isWindows);
testRoot.childDirectory(allPackagesProjectName).path)));
});
test('fails if flutter pub get fails', () async {
test('fails if flutter create fails', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
processRunner.mockProcessesForExecutable[
getFlutterCommand(const LocalPlatform())] = <FakeProcessInfo>[
processRunner
.mockProcessesForExecutable[getFlutterCommand(mockPlatform)] =
<FakeProcessInfo>[
FakeProcessInfo(MockProcess(exitCode: 1), <String>['create'])
];
Error? commandError;
final List<String> output = await runCapturingPrint(
runner, <String>['create-all-packages-app'], errorHandler: (Error e) {
commandError = e;
});
expect(commandError, isA<ToolExit>());
expect(
output,
containsAllInOrder(<Matcher>[
contains('Failed to `flutter create`'),
]));
});
test('fails if flutter pub get fails', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
processRunner
.mockProcessesForExecutable[getFlutterCommand(mockPlatform)] =
<FakeProcessInfo>[
FakeProcessInfo(MockProcess(), <String>['create']),
FakeProcessInfo(MockProcess(exitCode: 1), <String>['pub', 'get'])
];
Error? commandError;
@ -182,20 +466,22 @@ void main() {
skip: io.Platform.isWindows);
test('handles --output-dir', () async {
createFakePlugin('plugina', packagesDir);
final Directory customOutputDir =
fileSystem.systemTempDirectory.createTempSync();
writeFakeFlutterCreateOutput(customOutputDir);
createFakePlugin('plugina', packagesDir);
await runCapturingPrint(runner, <String>[
'create-all-packages-app',
'--output-dir=${customOutputDir.path}'
]);
expect(command.app.path,
customOutputDir.childDirectory('all_packages').path);
customOutputDir.childDirectory(allPackagesProjectName).path);
});
test('logs exclusions', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
createFakePlugin('pluginb', packagesDir);
createFakePlugin('pluginc', packagesDir);
@ -219,7 +505,6 @@ void main() {
packagesDir,
processRunner: processRunner,
platform: MockPlatform(isMacOS: true),
pluginsRoot: testRoot,
);
runner = CommandRunner<void>(
'create_all_test', 'Test for $CreateAllPackagesAppCommand');
@ -227,10 +512,11 @@ void main() {
});
test('macOS deployment target is modified in Podfile', () async {
writeFakeFlutterCreateOutput(testRoot);
createFakePlugin('plugina', packagesDir);
final File podfileFile = RepositoryPackage(
command.packagesDir.parent.childDirectory('all_packages'))
command.packagesDir.parent.childDirectory(allPackagesProjectName))
.platformDirectory(FlutterPlatform.macos)
.childFile('Podfile');
podfileFile.createSync(recursive: true);