fix(shorebird_cli): handle multi-dimensional flavors on android (#1960)

Co-authored-by: Felix Angelov <felix@shorebird.dev>
Co-authored-by: Bryan Oltman <bryan@shorebird.dev>
This commit is contained in:
Erick
2024-04-24 14:53:02 -03:00
committed by GitHub
parent 09507fa03d
commit ad3be5540a
6 changed files with 662 additions and 53 deletions
@@ -18,6 +18,7 @@ import 'package:shorebird_cli/src/patch_diff_checker.dart';
import 'package:shorebird_cli/src/platform.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/pubspec_editor.dart';
import 'package:shorebird_cli/src/shorebird_android_artifacts.dart';
import 'package:shorebird_cli/src/shorebird_artifacts.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
@@ -55,6 +56,7 @@ Future<void> main(List<String> args) async {
platformRef,
processRef,
pubspecEditorRef,
shorebirdAndroidArtifactsRef,
shorebirdArtifactsRef,
shorebirdEnvRef,
shorebirdFlutterRef,
@@ -1,7 +1,6 @@
import 'dart:io';
import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/command.dart';
@@ -12,6 +11,7 @@ import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/platform.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/shorebird_android_artifacts.dart';
import 'package:shorebird_cli/src/shorebird_build_mixin.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
@@ -195,43 +195,35 @@ Use `shorebird flutter versions list` to list available versions.
final appId = shorebirdYaml.getAppId(flavor: flavor);
final app = await codePushClientWrapper.getApp(appId: appId);
final bundleDirPath = p.join(
projectRoot.path,
'build',
'app',
'outputs',
'bundle',
);
final apkDirPath = p.join(
projectRoot.path,
'build',
'app',
'outputs',
'apk',
);
final bundlePath = flavor != null
? p.join(
bundleDirPath,
'${flavor}Release',
'app-$flavor-release.aab',
)
: p.join(bundleDirPath, 'release', 'app-release.aab');
final apkPath = flavor != null
? p.join(
apkDirPath,
flavor,
'release',
'app-$flavor-release.apk',
)
: p.join(apkDirPath, 'release', 'app-release.apk');
late final File apkFile;
final File aabFile;
try {
aabFile = shorebirdAndroidArtifacts.findAab(
project: projectRoot,
flavor: flavor,
);
if (generateApk) {
apkFile = shorebirdAndroidArtifacts.findApk(
project: projectRoot,
flavor: flavor,
);
}
} on ArtifactNotFoundException catch (error) {
logger.err(error.toString());
return ExitCode.software.code;
} on MultipleArtifactsFoundException catch (error) {
logger.err(error.toString());
return ExitCode.software.code;
}
final String releaseVersion;
final detectReleaseVersionProgress = logger.progress(
'Detecting release version',
);
try {
releaseVersion = await extractReleaseVersionFromAppBundle(bundlePath);
releaseVersion = await extractReleaseVersionFromAppBundle(
aabFile.path,
);
detectReleaseVersionProgress.complete();
} catch (error) {
detectReleaseVersionProgress.fail('$error');
@@ -308,7 +300,7 @@ ${summary.join('\n')}
appId: app.appId,
releaseId: release.id,
projectRoot: projectRoot.path,
aabPath: bundlePath,
aabPath: aabFile.path,
platform: releasePlatform,
architectures: architectures,
flavor: flavor,
@@ -338,7 +330,7 @@ ${summary.join('\n')}
? '''
Or distribute the apk:
${lightCyan.wrap(apkPath)}
${lightCyan.wrap(apkFile.path)}
'''
: '';
@@ -347,7 +339,7 @@ ${lightCyan.wrap(apkPath)}
..info('''
Your next step is to upload the app bundle to the Play Store:
${lightCyan.wrap(bundlePath)}
${lightCyan.wrap(aabFile.path)}
$apkText
For information on uploading to the Play Store, see:
${link(uri: Uri.parse('https://support.google.com/googleplay/android-developer/answer/9859152?hl=en'))}
@@ -0,0 +1,158 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/command.dart';
/// Thrown when multiple artifacts are found in the build directory.
class MultipleArtifactsFoundException implements Exception {
MultipleArtifactsFoundException({
required this.buildDir,
required this.foundArtifacts,
});
final String buildDir;
final List<FileSystemEntity> foundArtifacts;
@override
String toString() {
return 'Multiple artifacts found in $buildDir: '
'${foundArtifacts.map((e) => e.path)}';
}
}
/// Thrown when no artifact is found in the build directory.
class ArtifactNotFoundException implements Exception {
ArtifactNotFoundException({
required this.artifactName,
required this.buildDir,
});
final String artifactName;
final String buildDir;
@override
String toString() {
return 'Artifact $artifactName not found in $buildDir';
}
}
/// When building android artifacts, gradlew names these artifacts in an
/// inconsistent way.
///
/// Example:
///
/// ## AABs
/// Without flavors
/// ...bundle/release/app-release.aab
///
/// With flavors
/// ...bundle/flavor/app-flavor-release.aab
///
/// With multi dimensional flavors
/// ...bundle/fullFlavorCamelCase/app-flavor1-flavor2-release.aab
///
/// The pattern follows for APKs.
///
/// The only thing that is consistent is the artifactId which is the name of the
/// artifact will follow always the same order of name of the flavors.
///
/// To get around this, we create an identifier for the artifact
/// that is the file name lowercased and without any non-word characters.
/// This allows us to reliably find the artifact generated by the
/// flutter build command.
extension on String {
String get artifactId => replaceAll(RegExp(r'\W'), '').toLowerCase();
}
final shorebirdAndroidArtifactsRef = create(ShorebirdAndroidArtifacts.new);
ShorebirdAndroidArtifacts get shorebirdAndroidArtifacts =>
read(shorebirdAndroidArtifactsRef);
/// Mixin on [ShorebirdCommand] which exposes methods
// to find the artifacts generated for android
class ShorebirdAndroidArtifacts {
/// Find the artifact in the build directory.
File _findArtifact({
required String artifactName,
required Directory directory,
}) {
// Remove all non characters and digits from the artifact name.
final artifactId = artifactName.artifactId;
if (!directory.existsSync()) {
throw ArtifactNotFoundException(
artifactName: artifactName,
buildDir: directory.path,
);
}
final allFiles = directory.listSync();
final artifactCandidates = allFiles.whereType<File>().where((file) {
final fileName = p.basename(file.path);
return fileName.artifactId == artifactId;
}).toList();
if (artifactCandidates.isEmpty) {
throw ArtifactNotFoundException(
artifactName: artifactName,
buildDir: directory.path,
);
}
if (artifactCandidates.length > 1) {
throw MultipleArtifactsFoundException(
buildDir: directory.path,
foundArtifacts: artifactCandidates,
);
}
return artifactCandidates.first;
}
/// Find the app bundle in the provided [project] [Directory].
File findAab({
required Directory project,
required String? flavor,
}) {
final buildDir = p.join(
project.path,
'build',
'app',
'outputs',
'bundle',
flavor != null ? '${flavor}Release' : 'release',
);
final artifactName =
flavor == null ? 'app-release.aab' : 'app-$flavor-release.aab';
return _findArtifact(
directory: Directory(buildDir),
artifactName: artifactName,
);
}
/// Find the apk in the provided [project] [Directory].
File findApk({
required Directory project,
required String? flavor,
}) {
final buildDir = p.join(
project.path,
'build',
'app',
'outputs',
'flutter-apk',
);
final artifactName =
flavor == null ? 'app-release.apk' : 'app-$flavor-release.apk';
return _findArtifact(
directory: Directory(buildDir),
artifactName: artifactName,
);
}
}
@@ -18,6 +18,7 @@ import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/os/operating_system_interface.dart';
import 'package:shorebird_cli/src/platform.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/shorebird_android_artifacts.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
import 'package:shorebird_cli/src/shorebird_process.dart';
@@ -83,8 +84,12 @@ void main() {
late ShorebirdEnv shorebirdEnv;
late ShorebirdFlutter shorebirdFlutter;
late ShorebirdValidator shorebirdValidator;
late ShorebirdAndroidArtifacts shorebirdAndroidArtifacts;
late ReleaseAndroidCommand command;
late String aabPath;
late String apkPath;
R runWithOverrides<R>(R Function() body) {
return runScoped(
body,
@@ -102,6 +107,8 @@ void main() {
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
shorebirdFlutterRef.overrideWith(() => shorebirdFlutter),
shorebirdValidatorRef.overrideWith(() => shorebirdValidator),
shorebirdAndroidArtifactsRef
.overrideWith(() => shorebirdAndroidArtifacts),
},
);
}
@@ -111,6 +118,7 @@ void main() {
registerFallbackValue(ReleaseStatus.draft);
registerFallbackValue(FakeRelease());
registerFallbackValue(FakeShorebirdProcess());
registerFallbackValue(Directory(''));
});
setUp(() {
@@ -134,6 +142,28 @@ void main() {
shorebirdEnv = MockShorebirdEnv();
shorebirdFlutter = MockShorebirdFlutter();
shorebirdValidator = MockShorebirdValidator();
shorebirdAndroidArtifacts = ShorebirdAndroidArtifacts();
aabPath = p.join(
projectRoot.path,
'build',
'app',
'outputs',
'bundle',
'release',
'app-release.aab',
);
apkPath = p.join(
projectRoot.path,
'build',
'app',
'outputs',
'flutter-apk',
'app-release.apk',
);
File(apkPath).createSync(recursive: true);
File(aabPath).createSync(recursive: true);
when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml);
when(
@@ -555,24 +585,6 @@ ${link(uri: Uri.parse('https://support.google.com/googleplay/android-developer/a
final exitCode = await runWithOverrides(command.run);
verify(() => logger.success('\n✅ Published Release $version!')).called(1);
// Verify info message does include apk instructions.
final aabPath = p.join(
projectRoot.path,
'build',
'app',
'outputs',
'bundle',
'release',
'app-release.aab',
);
final apkPath = p.join(
projectRoot.path,
'build',
'app',
'outputs',
'apk',
'release',
'app-release.apk',
);
verify(
() => logger.info('''
@@ -760,6 +772,26 @@ Either run `flutter pub get` manually, or follow the steps in ${link(uri: Uri.pa
test(
'succeeds when release is successful '
'with flavors and target', () async {
aabPath = p.join(
projectRoot.path,
'build',
'app',
'outputs',
'bundle',
'developmentRelease',
'app-development-release.aab',
);
apkPath = p.join(
projectRoot.path,
'build',
'app',
'outputs',
'flutter-apk',
'app-development-release.apk',
);
File(apkPath).createSync(recursive: true);
File(aabPath).createSync(recursive: true);
const flavor = 'development';
final target = p.join('lib', 'main_development.dart');
when(() => argResults['flavor']).thenReturn(flavor);
@@ -888,5 +920,72 @@ Note: ${lightCyan.wrap('shorebird patch android --flavor=$flavor --target=$targe
expect(exitCode, equals(ExitCode.success.code));
verifyNever(() => logger.confirm(any()));
});
test('errors when the app bundle cannot be found', () async {
shorebirdAndroidArtifacts = MockShorebirdAndroidArtifacts();
when(
() => shorebirdAndroidArtifacts.findAab(
project: any(named: 'project'),
flavor: any(named: 'flavor'),
),
).thenThrow(
ArtifactNotFoundException(
artifactName: 'app-release.aab',
buildDir: 'buildDir',
),
);
final exitCode = await runWithOverrides(command.run);
verify(
() => logger.err('Artifact app-release.aab not found in buildDir'),
).called(1);
expect(exitCode, ExitCode.software.code);
});
test('errors when the apk cannot be found', () async {
shorebirdAndroidArtifacts = MockShorebirdAndroidArtifacts();
when(() => argResults['artifact']).thenReturn('apk');
when(
() => shorebirdAndroidArtifacts.findAab(
project: any(named: 'project'),
flavor: any(named: 'flavor'),
),
).thenReturn(File('app-release.aab'));
when(
() => shorebirdAndroidArtifacts.findApk(
project: any(named: 'project'),
flavor: any(named: 'flavor'),
),
).thenThrow(
ArtifactNotFoundException(
artifactName: 'app-release.apk',
buildDir: 'buildDir',
),
);
final exitCode = await runWithOverrides(command.run);
verify(
() => logger.err('Artifact app-release.apk not found in buildDir'),
).called(1);
expect(exitCode, ExitCode.software.code);
});
test('errors when multiple artifacts are found', () async {
shorebirdAndroidArtifacts = MockShorebirdAndroidArtifacts();
when(
() => shorebirdAndroidArtifacts.findAab(
project: any(named: 'project'),
flavor: any(named: 'flavor'),
),
).thenThrow(
MultipleArtifactsFoundException(
foundArtifacts: [File('a'), File('b')],
buildDir: 'buildDir',
),
);
final exitCode = await runWithOverrides(command.run);
verify(
() => logger.err('Multiple artifacts found in buildDir: (a, b)'),
).called(1);
expect(exitCode, ExitCode.software.code);
});
});
}
@@ -24,6 +24,7 @@ import 'package:shorebird_cli/src/os/os.dart';
import 'package:shorebird_cli/src/patch_diff_checker.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/pubspec_editor.dart';
import 'package:shorebird_cli/src/shorebird_android_artifacts.dart';
import 'package:shorebird_cli/src/shorebird_artifacts.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
@@ -124,6 +125,9 @@ class MockRelease extends Mock implements Release {}
class MockReleaseArtifact extends Mock implements ReleaseArtifact {}
class MockShorebirdAndroidArtifacts extends Mock
implements ShorebirdAndroidArtifacts {}
class MockShorebirdArtifacts extends Mock implements ShorebirdArtifacts {}
class MockShorebirdEnv extends Mock implements ShorebirdEnv {}
@@ -0,0 +1,354 @@
import 'package:path/path.dart' as path;
import 'package:shorebird_cli/src/shorebird_android_artifacts.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
import 'package:test/test.dart';
void main() {
group(ShorebirdAndroidArtifacts, () {
late Directory project;
late ShorebirdAndroidArtifacts shorebirdAndroidArtifacts;
setUp(() {
project = Directory.systemTemp.createTempSync();
shorebirdAndroidArtifacts = ShorebirdAndroidArtifacts();
});
group('when no build folder exists', () {
test('throws ArtifactNotFoundException for aabs', () {
expect(
() => shorebirdAndroidArtifacts.findAab(
project: project,
flavor: null,
),
throwsA(isA<ArtifactNotFoundException>()),
);
});
test('throws ArtifactNotFoundException for apks', () {
expect(
() => shorebirdAndroidArtifacts.findApk(
project: project,
flavor: null,
),
throwsA(isA<ArtifactNotFoundException>()),
);
});
});
group('when build folder exists but not the file', () {
test('throws ArtifactNotFoundException for aabs', () {
Directory(
path.join(
project.path,
'build',
'app',
'outputs',
'bundle',
'release',
),
).createSync(recursive: true);
expect(
() => shorebirdAndroidArtifacts.findAab(
project: project,
flavor: null,
),
throwsA(isA<ArtifactNotFoundException>()),
);
});
test('throws ArtifactNotFoundException for apks', () {
Directory(
path.join(
project.path,
'build',
'app',
'outputs',
'flutter-apk',
),
).createSync(recursive: true);
expect(
() => shorebirdAndroidArtifacts.findApk(
project: project,
flavor: null,
),
throwsA(isA<ArtifactNotFoundException>()),
);
});
});
group('when using no flavors', () {
test('finds the app bundle flavors', () {
final artifactPath = path.join(
'build',
'app',
'outputs',
'bundle',
'release',
'app-release.aab',
);
final artifact = File(
path.join(project.path, artifactPath),
)..createSync(recursive: true);
expect(
shorebirdAndroidArtifacts
.findAab(
project: project,
flavor: null,
)
.path,
equals(artifact.path),
);
});
test('finds the apk', () {
final artifactPath = path.join(
'build',
'app',
'outputs',
'flutter-apk',
'app-release.apk',
);
final artifact = File(
path.join(project.path, artifactPath),
)..createSync(recursive: true);
expect(
shorebirdAndroidArtifacts
.findApk(
project: project,
flavor: null,
)
.path,
equals(artifact.path),
);
});
});
group('when using single-dimensional flavor', () {
test('finds the app bundle', () {
final artifactPath = path.join(
'build',
'app',
'outputs',
'bundle',
'internalRelease',
'app-internal-release.aab',
);
final artifact = File(
path.join(project.path, artifactPath),
)..createSync(recursive: true);
const flavor = 'internal';
expect(
shorebirdAndroidArtifacts
.findAab(
project: project,
flavor: flavor,
)
.path,
equals(artifact.path),
);
});
test('finds the apk', () {
final artifactPath = path.join(
'build',
'app',
'outputs',
'flutter-apk',
'app-internal-release.apk',
);
final artifact = File(
path.join(project.path, artifactPath),
)..createSync(recursive: true);
const flavor = 'internal';
expect(
shorebirdAndroidArtifacts
.findApk(
project: project,
flavor: flavor,
)
.path,
equals(artifact.path),
);
});
});
group('when using multi-dimensional flavors', () {
test('finds the app bundle', () {
final artifactPath = path.join(
'build',
'app',
'outputs',
'bundle',
'stableGlobalRelease',
'app-stable-global-release.aab',
);
final artifact = File(
path.join(project.path, artifactPath),
)..createSync(recursive: true);
const flavor = 'stableGlobal';
expect(
shorebirdAndroidArtifacts
.findAab(
project: project,
flavor: flavor,
)
.path,
equals(artifact.path),
);
});
test('finds the apk', () {
final artifactPath = path.join(
'build',
'app',
'outputs',
'flutter-apk',
'app-stableglobal-release.apk',
);
final artifact = File(
path.join(project.path, artifactPath),
)..createSync(recursive: true);
const flavor = 'stableGlobal';
expect(
shorebirdAndroidArtifacts
.findApk(
project: project,
flavor: flavor,
)
.path,
equals(artifact.path),
);
});
});
group('when using multi-dimensional flavors and multi-word flavor name',
() {
test('finds the app bundle', () {
final artifactPath = path.join(
'build',
'app',
'outputs',
'bundle',
'stablePlayStoreRelease',
'app-stable-playStore-release.aab',
);
final artifact = File(
path.join(project.path, artifactPath),
)..createSync(recursive: true);
const flavor = 'stablePlayStore';
expect(
shorebirdAndroidArtifacts
.findAab(
project: project,
flavor: flavor,
)
.path,
equals(artifact.path),
);
});
test('finds the apk', () {
final artifactPath = path.join(
'build',
'app',
'outputs',
'flutter-apk',
'app-stableplaystore-release.apk',
);
final artifact = File(
path.join(project.path, artifactPath),
)..createSync(recursive: true);
const flavor = 'stablePlayStore';
expect(
shorebirdAndroidArtifacts
.findApk(
project: project,
flavor: flavor,
)
.path,
equals(artifact.path),
);
});
});
group('when multiple files are found', () {
test('throws MultipleArtifactsFoundException when looking for aab', () {
final duplicatedArtifactPath = path.join(
'build',
'app',
'outputs',
'bundle',
'stablePlayStoreRelease',
'app---stable-playStore-release.aab',
);
File(
path.join(project.path, duplicatedArtifactPath),
).createSync(recursive: true);
const artifactPath =
'build/app/outputs/bundle/stablePlayStoreRelease/app-stable-playStore-release.aab';
File(
path.join(project.path, artifactPath),
).createSync(recursive: true);
const flavor = 'stablePlayStore';
expect(
() => shorebirdAndroidArtifacts.findAab(
project: project,
flavor: flavor,
),
throwsA(isA<MultipleArtifactsFoundException>()),
);
});
test('throws MultipleArtifactsFoundException when looking for apk', () {
final duplicatedArtifactPath = path.join(
'build',
'app',
'outputs',
'flutter-apk',
'app----stableplaystore-release.apk',
);
File(
path.join(project.path, duplicatedArtifactPath),
).createSync(recursive: true);
final artifactPath = path.join(
'build',
'app',
'outputs',
'flutter-apk',
'app-stableplaystore-release.apk',
);
File(
path.join(project.path, artifactPath),
).createSync(recursive: true);
const flavor = 'stablePlayStore';
expect(
() => shorebirdAndroidArtifacts.findApk(
project: project,
flavor: flavor,
),
throwsA(isA<MultipleArtifactsFoundException>()),
);
});
});
});
}