diff --git a/packages/shorebird_cli/bin/shorebird.dart b/packages/shorebird_cli/bin/shorebird.dart index d3b96ddc..91882649 100644 --- a/packages/shorebird_cli/bin/shorebird.dart +++ b/packages/shorebird_cli/bin/shorebird.dart @@ -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 main(List args) async { platformRef, processRef, pubspecEditorRef, + shorebirdAndroidArtifactsRef, shorebirdArtifactsRef, shorebirdEnvRef, shorebirdFlutterRef, diff --git a/packages/shorebird_cli/lib/src/commands/release/release_android_command.dart b/packages/shorebird_cli/lib/src/commands/release/release_android_command.dart index ca53b411..c0b2707d 100644 --- a/packages/shorebird_cli/lib/src/commands/release/release_android_command.dart +++ b/packages/shorebird_cli/lib/src/commands/release/release_android_command.dart @@ -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'))} diff --git a/packages/shorebird_cli/lib/src/shorebird_android_artifacts.dart b/packages/shorebird_cli/lib/src/shorebird_android_artifacts.dart new file mode 100644 index 00000000..86867e0e --- /dev/null +++ b/packages/shorebird_cli/lib/src/shorebird_android_artifacts.dart @@ -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 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().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, + ); + } +} diff --git a/packages/shorebird_cli/test/src/commands/release/release_android_command_test.dart b/packages/shorebird_cli/test/src/commands/release/release_android_command_test.dart index 490df53d..8e116443 100644 --- a/packages/shorebird_cli/test/src/commands/release/release_android_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/release_android_command_test.dart @@ -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 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); + }); }); } diff --git a/packages/shorebird_cli/test/src/mocks.dart b/packages/shorebird_cli/test/src/mocks.dart index 49a48a88..b583f5f8 100644 --- a/packages/shorebird_cli/test/src/mocks.dart +++ b/packages/shorebird_cli/test/src/mocks.dart @@ -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 {} diff --git a/packages/shorebird_cli/test/src/shorebird_android_artifacts_test.dart b/packages/shorebird_cli/test/src/shorebird_android_artifacts_test.dart new file mode 100644 index 00000000..c8980d89 --- /dev/null +++ b/packages/shorebird_cli/test/src/shorebird_android_artifacts_test.dart @@ -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()), + ); + }); + + test('throws ArtifactNotFoundException for apks', () { + expect( + () => shorebirdAndroidArtifacts.findApk( + project: project, + flavor: null, + ), + throwsA(isA()), + ); + }); + }); + + 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()), + ); + }); + + 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()), + ); + }); + }); + + 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()), + ); + }); + + 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()), + ); + }); + }); + }); +}