diff --git a/packages/shorebird_cli/lib/src/commands/commands.dart b/packages/shorebird_cli/lib/src/commands/commands.dart index b1154d17b..5d9d1434f 100644 --- a/packages/shorebird_cli/lib/src/commands/commands.dart +++ b/packages/shorebird_cli/lib/src/commands/commands.dart @@ -9,5 +9,6 @@ export 'patch/patch.dart'; export 'patches/patches.dart'; export 'preview_command.dart'; export 'release/release.dart'; +export 'releases/releases.dart'; export 'run_command.dart'; export 'upgrade_command.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/preview_command.dart b/packages/shorebird_cli/lib/src/commands/preview_command.dart index 4cfaa045c..45ccc8498 100644 --- a/packages/shorebird_cli/lib/src/commands/preview_command.dart +++ b/packages/shorebird_cli/lib/src/commands/preview_command.dart @@ -13,6 +13,7 @@ import 'package:path/path.dart' as p; import 'package:shorebird_cli/src/artifact_manager.dart'; import 'package:shorebird_cli/src/cache.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/common_arguments.dart'; import 'package:shorebird_cli/src/deployment_track.dart'; import 'package:shorebird_cli/src/executables/devicectl/apple_device.dart'; import 'package:shorebird_cli/src/executables/executables.dart'; @@ -43,8 +44,8 @@ class PreviewCommand extends ShorebirdCommand { help: 'The ID of the app to preview the release for.', ) ..addOption( - 'release-version', - help: 'The version of the release (e.g. "1.0.0").', + CommonArguments.releaseVersionArg.name, + help: CommonArguments.releaseVersionArg.description, ) ..addOption( 'platform', diff --git a/packages/shorebird_cli/lib/src/commands/releases/get_apks_command.dart b/packages/shorebird_cli/lib/src/commands/releases/get_apks_command.dart new file mode 100644 index 000000000..a54a69ed5 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/releases/get_apks_command.dart @@ -0,0 +1,187 @@ +import 'dart:io'; + +import 'package:archive/archive_io.dart'; +import 'package:collection/collection.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as p; +import 'package:shorebird_cli/src/artifact_manager.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/common_arguments.dart'; +import 'package:shorebird_cli/src/config/config.dart'; +import 'package:shorebird_cli/src/executables/bundletool.dart'; +import 'package:shorebird_cli/src/extensions/arg_results.dart'; +import 'package:shorebird_cli/src/logging/shorebird_logger.dart'; +import 'package:shorebird_cli/src/shorebird_command.dart'; +import 'package:shorebird_cli/src/shorebird_env.dart'; +import 'package:shorebird_cli/src/shorebird_validator.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; + +/// {@template get_apk_command} +/// Generates an APK for the release with the specified version. +/// {@endtemplate} +class GetApksCommand extends ShorebirdCommand { + /// {@macro get_apk_command} + GetApksCommand() { + argParser + ..addOption( + CommonArguments.releaseVersionArg.name, + help: 'The release version to generate apks for', + ) + ..addOption( + CommonArguments.flavorArg.name, + help: 'The build flavor to generate an apks for', + ) + ..addOption( + 'out', + abbr: 'o', + help: 'The output directory for the generated apks', + ) + ..addFlag( + 'universal', + defaultsTo: true, + help: 'Whether to generate a universal apk. Defaults to true.', + ); + } + + @override + String get name => 'get-apks'; + + @override + String get description => + 'Generates apk(s) for the specified release version'; + + /// The shorebird app ID for the current project. + String get appId => shorebirdEnv.getShorebirdYaml()!.getAppId(flavor: flavor); + + /// The build flavor, if provided. + late String? flavor = results.findOption( + CommonArguments.flavorArg.name, + argParser: argParser, + ); + + /// The output directory path for the generated apks. Defaults to the + /// project's build directory if not provided. + late String? outDirectoryArg = results.findOption( + 'out', + argParser: argParser, + ); + + @override + Future run() async { + try { + await shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + checkShorebirdInitialized: true, + ); + } on PreconditionFailedException catch (error) { + return error.exitCode.code; + } + + final Release release; + if (results.wasParsed(CommonArguments.releaseVersionArg.name)) { + final releaseVersion = + results[CommonArguments.releaseVersionArg.name] as String; + release = await codePushClientWrapper.getRelease( + appId: appId, + releaseVersion: releaseVersion, + ); + } else { + release = await _promptForRelease(); + } + + final releaseArtifact = await codePushClientWrapper.getReleaseArtifact( + appId: appId, + releaseId: release.id, + arch: 'aab', + platform: ReleasePlatform.android, + ); + + final aabFile = await _downloadAab(releaseArtifact: releaseArtifact); + final apksFile = File( + p.join( + Directory.systemTemp.createTempSync().path, + '${appId}_${release.version}.apks', + ), + ); + + final buildApksProgress = logger.progress( + 'Building apks for release ${release.version} (app: $appId)', + ); + try { + await bundletool.buildApks( + bundle: aabFile.path, + output: apksFile.path, + universal: results['universal'] as bool, + ); + buildApksProgress.complete(); + } catch (error) { + buildApksProgress.fail('$error'); + return ExitCode.software.code; + } + + final apksZipFile = apksFile.renameSync('${apksFile.path}.zip'); + + final Directory outputDirectory; + if (outDirectoryArg != null) { + outputDirectory = Directory(outDirectoryArg!); + if (!outputDirectory.existsSync()) { + outputDirectory.createSync(recursive: true); + } + } else { + // The output of `flutter build apk` is build/app/outputs/flutter-apk, + // so we move the generated apk to build/app/outputs/shorebird-apk. + outputDirectory = Directory( + p.join( + shorebirdEnv.getShorebirdProjectRoot()!.path, + 'build', + 'app', + 'outputs', + 'shorebird-apk', + ), + )..createSync(recursive: true); + } + + await extractFileToDisk( + apksZipFile.path, + outputDirectory.path, + ); + + logger.info('apk(s) generated at ${lightCyan.wrap(outputDirectory.path)}'); + return ExitCode.success.code; + } + + Future _promptForRelease() async { + final releases = await codePushClientWrapper.getReleases( + appId: appId, + sideloadableOnly: true, + ); + + if (releases.isEmpty) { + logger.err('No releases found for app $appId'); + throw ProcessExit(ExitCode.usage.code); + } + + return logger.chooseOne( + 'Which release would you like to generate an apk for?', + choices: releases.sortedBy((r) => r.createdAt).reversed.toList(), + display: (r) => r.version, + ); + } + + Future _downloadAab({ + required ReleaseArtifact releaseArtifact, + }) async { + final File artifactFile; + try { + artifactFile = await artifactManager.downloadWithProgressUpdates( + Uri.parse(releaseArtifact.url), + message: 'Downloading aab', + ); + } catch (_) { + throw ProcessExit(ExitCode.software.code); + } + + return artifactFile; + } +} diff --git a/packages/shorebird_cli/lib/src/commands/releases/releases.dart b/packages/shorebird_cli/lib/src/commands/releases/releases.dart new file mode 100644 index 000000000..89150aa37 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/releases/releases.dart @@ -0,0 +1,2 @@ +export 'get_apks_command.dart'; +export 'releases_command.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/releases/releases_command.dart b/packages/shorebird_cli/lib/src/commands/releases/releases_command.dart new file mode 100644 index 000000000..055c8e9c1 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/releases/releases_command.dart @@ -0,0 +1,18 @@ +import 'package:shorebird_cli/src/commands/releases/releases.dart'; +import 'package:shorebird_cli/src/shorebird_command.dart'; + +/// {@template releases_command} +/// Commands for managing Shorebird releases. +/// {@endtemplate} +class ReleasesCommand extends ShorebirdCommand { + /// {@macro releases_command} + ReleasesCommand() { + addSubcommand(GetApksCommand()); + } + + @override + String get name => 'releases'; + + @override + String get description => 'Manage Shorebird releases'; +} diff --git a/packages/shorebird_cli/lib/src/common_arguments.dart b/packages/shorebird_cli/lib/src/common_arguments.dart index f7c583df0..722fb13e4 100644 --- a/packages/shorebird_cli/lib/src/common_arguments.dart +++ b/packages/shorebird_cli/lib/src/common_arguments.dart @@ -84,6 +84,14 @@ Entries from "--dart-define" with identical keys take precedence over entries fr '''Export an IPA with these options. See "xcodebuild -h" for available exportOptionsPlist keys (iOS only).''', ); + /// An argument that allows the user to specify a build flavor. You will most + /// likely want to provide a custom description for this argument that more + /// thoroughly explains what the flavor is used for. + static const flavorArg = ArgumentDescriber( + name: 'flavor', + description: 'The app flavor', + ); + /// An argument that allows the user to specify a public key file that will be /// used to validate patch signatures. static const publicKeyArg = ArgumentDescriber( @@ -102,6 +110,14 @@ The path for a private key .pem file that will be used to sign the patch artifac ''', ); + /// An argument that allows the user to specify a release version. You will + /// most likely want to provide a custom description for this argument that + /// more thoroughly explains what the release version is used for. + static const releaseVersionArg = ArgumentDescriber( + name: 'release-version', + description: 'The version of the release (e.g. "1.0.0").', + ); + /// An argument that allows the user to specify a directory where program /// symbols are stored. static const splitDebugInfoArg = ArgumentDescriber( diff --git a/packages/shorebird_cli/lib/src/executables/bundletool.dart b/packages/shorebird_cli/lib/src/executables/bundletool.dart index f438c17e5..f057a627c 100644 --- a/packages/shorebird_cli/lib/src/executables/bundletool.dart +++ b/packages/shorebird_cli/lib/src/executables/bundletool.dart @@ -44,6 +44,7 @@ class Bundletool { Future buildApks({ required String bundle, required String output, + bool universal = true, }) async { final result = await _exec( [ @@ -51,7 +52,7 @@ class Bundletool { '--overwrite', '--bundle=$bundle', '--output=$output', - '--mode=universal', + if (universal) '--mode=universal', ], ); if (result.exitCode != 0) { diff --git a/packages/shorebird_cli/lib/src/shorebird_cli_command_runner.dart b/packages/shorebird_cli/lib/src/shorebird_cli_command_runner.dart index cc20f4920..9ca2c1a2c 100644 --- a/packages/shorebird_cli/lib/src/shorebird_cli_command_runner.dart +++ b/packages/shorebird_cli/lib/src/shorebird_cli_command_runner.dart @@ -78,6 +78,7 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner { addCommand(PatchesCommand()); addCommand(PreviewCommand()); addCommand(ReleaseCommand()); + addCommand(ReleasesCommand()); addCommand(RunCommand()); addCommand(UpgradeCommand()); } diff --git a/packages/shorebird_cli/test/src/commands/releases/get_apks_command_test.dart b/packages/shorebird_cli/test/src/commands/releases/get_apks_command_test.dart new file mode 100644 index 000000000..56f7c5f11 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/releases/get_apks_command_test.dart @@ -0,0 +1,424 @@ +import 'dart:io'; + +import 'package:archive/archive_io.dart'; +import 'package:args/args.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:path/path.dart' as p; +import 'package:scoped_deps/scoped_deps.dart'; +import 'package:shorebird_cli/src/artifact_manager.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/commands/releases/releases.dart'; +import 'package:shorebird_cli/src/config/config.dart'; +import 'package:shorebird_cli/src/executables/bundletool.dart'; +import 'package:shorebird_cli/src/logging/shorebird_logger.dart'; +import 'package:shorebird_cli/src/shorebird_env.dart'; +import 'package:shorebird_cli/src/shorebird_validator.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; +import 'package:test/test.dart'; + +import '../../mocks.dart'; + +void main() { + group(GetApksCommand, () { + const appId = 'test-app-id'; + const releaseId = 123; + const releaseVersion = '1.2.3'; + const releaseArtifactUrl = 'https://example.com/release.aab'; + const apkFileName = '${appId}_$releaseVersion.apk'; + + late ArgResults argResults; + late ArtifactManager artifactManager; + late Bundletool bundletool; + late CodePushClientWrapper codePushClientWrapper; + late Progress progress; + late Directory projectRoot; + late Release release; + late ReleaseArtifact releaseArtifact; + late ShorebirdEnv shorebirdEnv; + late ShorebirdLogger logger; + late ShorebirdValidator shorebirdValidator; + late ShorebirdYaml shorebirdYaml; + + late GetApksCommand command; + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + artifactManagerRef.overrideWith(() => artifactManager), + bundletoolRef.overrideWith(() => bundletool), + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + loggerRef.overrideWith(() => logger), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + }, + ); + } + + /// Creates a zip file containing an apk file with the apks extension + Future createTempApksFile() async { + final tempDir = Directory.systemTemp.createTempSync(); + final apksDir = Directory(p.join(tempDir.path, 'temp.apks')) + ..createSync(recursive: true); + final apksFile = File(p.join(tempDir.path, 'test.apks')); + + // Write an "apk" to zip + File(p.join(apksDir.path, apkFileName)) + ..createSync(recursive: true) + ..writeAsStringSync('hello'); + ZipFileEncoder().zipDirectory( + apksDir, + filename: apksFile.path, + ); + return apksFile; + } + + setUpAll(() { + registerFallbackValue(Uri()); + }); + + setUp(() { + argResults = MockArgResults(); + artifactManager = MockArtifactManager(); + bundletool = MockBundleTool(); + codePushClientWrapper = MockCodePushClientWrapper(); + logger = MockShorebirdLogger(); + progress = MockProgress(); + projectRoot = Directory.systemTemp.createTempSync(); + release = MockRelease(); + releaseArtifact = MockReleaseArtifact(); + shorebirdEnv = MockShorebirdEnv(); + shorebirdValidator = MockShorebirdValidator(); + shorebirdYaml = MockShorebirdYaml(); + + when(() => argResults.wasParsed(any())).thenReturn(false); + when(() => argResults['universal']).thenReturn(true); + when(() => argResults.rest).thenReturn([]); + + when( + () => artifactManager.downloadWithProgressUpdates( + any(), + message: any(named: 'message'), + ), + ).thenAnswer((_) async => File('')); + + when( + () => bundletool.buildApks( + bundle: any(named: 'bundle'), + output: any(named: 'output'), + universal: any(named: 'universal'), + ), + ).thenAnswer((invocation) async { + final apksFile = await createTempApksFile(); + final outputPath = invocation.namedArguments[#output] as String; + apksFile.renameSync(outputPath); + }); + + when( + () => codePushClientWrapper.getRelease( + appId: any(named: 'appId'), + releaseVersion: any(named: 'releaseVersion'), + ), + ).thenAnswer((_) async => release); + when( + () => codePushClientWrapper.getReleases( + appId: any(named: 'appId'), + sideloadableOnly: any(named: 'sideloadableOnly'), + ), + ).thenAnswer((_) async => [release]); + when( + () => codePushClientWrapper.getReleaseArtifact( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + arch: any(named: 'arch'), + platform: ReleasePlatform.android, + ), + ).thenAnswer((_) async => releaseArtifact); + + when( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: any(named: 'display'), + ), + ).thenReturn(release); + when(() => logger.progress(any())).thenReturn(progress); + + when(() => release.id).thenReturn(releaseId); + when(() => release.version).thenReturn(releaseVersion); + + when(() => releaseArtifact.url).thenReturn(releaseArtifactUrl); + + when( + () => shorebirdEnv.getShorebirdProjectRoot(), + ).thenReturn(projectRoot); + when( + () => shorebirdEnv.getShorebirdYaml(), + ).thenReturn(shorebirdYaml); + + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), + ), + ).thenAnswer((_) async => {}); + + when(() => shorebirdYaml.appId).thenReturn(appId); + + command = GetApksCommand()..testArgResults = argResults; + }); + + test('has a description', () { + expect(command.description, isNotEmpty); + }); + + group('when validation fails', () { + final exception = ValidationFailedException(); + setUp(() { + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), + ), + ).thenThrow(exception); + }); + + test('exits with exit code from validation error', () async { + await expectLater( + runWithOverrides(command.run), + completion(equals(exception.exitCode.code)), + ); + verify( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + checkShorebirdInitialized: true, + ), + ).called(1); + }); + }); + + group('when querying for releases fails', () { + setUp(() { + when( + () => codePushClientWrapper.getReleases( + appId: any(named: 'appId'), + sideloadableOnly: any(named: 'sideloadableOnly'), + ), + ).thenThrow(ProcessExit(ExitCode.software.code)); + }); + + test('exits with code 70', () async { + await expectLater( + () => runWithOverrides(command.run), + throwsA( + isA().having( + (e) => e.exitCode, + 'exitCode', + ExitCode.software.code, + ), + ), + ); + verify( + () => codePushClientWrapper.getReleases( + appId: appId, + sideloadableOnly: true, + ), + ).called(1); + }); + }); + + group('when downloading aab fails', () { + final exception = Exception('oops'); + + setUp(() { + when( + () => artifactManager.downloadWithProgressUpdates( + any(), + message: any(named: 'message'), + ), + ).thenThrow(exception); + }); + + test('exits with code 70', () async { + await expectLater( + () => runWithOverrides(command.run), + throwsA( + isA().having( + (e) => e.exitCode, + 'exitCode', + ExitCode.software.code, + ), + ), + ); + }); + }); + + group('when app does not have any releases', () { + setUp(() { + when( + () => codePushClientWrapper.getReleases( + appId: appId, + sideloadableOnly: any(named: 'sideloadableOnly'), + ), + ).thenAnswer((_) async => []); + }); + + test('exits with code 70', () async { + await expectLater( + () => runWithOverrides(command.run), + throwsA( + isA().having( + (e) => e.exitCode, + 'exitCode', + ExitCode.usage.code, + ), + ), + ); + verify( + () => codePushClientWrapper.getReleases( + appId: appId, + sideloadableOnly: true, + ), + ).called(1); + verify(() => logger.err('No releases found for app $appId')).called(1); + }); + }); + + group('when release version is not specified', () { + setUp(() { + when(() => argResults.wasParsed('release-version')).thenReturn(false); + }); + + test('prompts for release', () async { + await runWithOverrides(command.run); + + final capturedDisplay = verify( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: captureAny(named: 'display'), + ), + ).captured.single as String Function(Release); + + expect(capturedDisplay(release), equals(releaseVersion)); + }); + }); + + group('when release version is specified', () { + const releaseVersionArg = '1.2.3'; + + setUp(() { + when(() => argResults['release-version']).thenReturn(releaseVersionArg); + when(() => argResults.wasParsed('release-version')).thenReturn(true); + }); + + test('queries for release with specified version', () async { + await runWithOverrides(command.run); + + verify( + () => codePushClientWrapper.getRelease( + appId: appId, + releaseVersion: releaseVersionArg, + ), + ).called(1); + }); + + test('does not prompt for release', () async { + await runWithOverrides(command.run); + + verifyNever( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: any(named: 'display'), + ), + ); + }); + }); + + group('when buildApk fails', () { + final exception = Exception('oops'); + + setUp(() { + when( + () => bundletool.buildApks( + bundle: any(named: 'bundle'), + output: any(named: 'output'), + universal: any(named: 'universal'), + ), + ).thenThrow(exception); + }); + + test('exits with code 70', () async { + await expectLater( + runWithOverrides(command.run), + completion(ExitCode.software.code), + ); + verify(() => progress.fail('$exception')).called(1); + }); + }); + + group('when output directory is specified', () { + late Directory outDirectory; + + setUp(() { + outDirectory = Directory.systemTemp.createTempSync(); + // Delete to ensure the command creates the directory if needed + // ignore: cascade_invocations + outDirectory.deleteSync(); + when(() => argResults['out']).thenReturn(outDirectory.path); + when(() => argResults.wasParsed('out')).thenReturn(true); + }); + + test('creates apk in specified directory', () async { + await expectLater( + runWithOverrides(command.run), + completion(ExitCode.success.code), + ); + final expectedMessage = + '''apk(s) generated at ${lightCyan.wrap(outDirectory.path)}'''; + verify(() => logger.info(expectedMessage)).called(1); + }); + }); + + group('when no output directory is specified', () { + test('creates apk in project build subdirectory', () async { + await expectLater( + runWithOverrides(command.run), + completion(ExitCode.success.code), + ); + + final apkPath = p.join( + projectRoot.path, + 'build', + 'app', + 'outputs', + 'shorebird-apk', + ); + final expectedMessage = + 'apk(s) generated at ${lightCyan.wrap(apkPath)}'; + verify(() => logger.info(expectedMessage)).called(1); + }); + }); + + group('when user passes --no-universal', () { + setUp(() { + when(() => argResults['universal']).thenReturn(false); + }); + + test('builds apks without universal flag', () async { + await runWithOverrides(command.run); + + verify( + () => bundletool.buildApks( + bundle: any(named: 'bundle'), + output: any(named: 'output'), + universal: false, + ), + ).called(1); + }); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/executables/bundletool_test.dart b/packages/shorebird_cli/test/src/executables/bundletool_test.dart index 60cdcdb01..0f6ef9483 100644 --- a/packages/shorebird_cli/test/src/executables/bundletool_test.dart +++ b/packages/shorebird_cli/test/src/executables/bundletool_test.dart @@ -128,6 +128,54 @@ void main() { ), ).called(1); }); + + group('when universal is set to false', () { + setUp(() { + when( + () => process.run( + any(), + any(), + environment: any(named: 'environment'), + ), + ).thenAnswer( + (_) async => const ShorebirdProcessResult( + exitCode: 0, + stdout: '', + stderr: '', + ), + ); + }); + + test('does not pass --mode=universal as an argument', () async { + await expectLater( + runWithOverrides( + () => bundletool.buildApks( + bundle: appBundlePath, + output: output, + universal: false, + ), + ), + completes, + ); + verify( + () => process.run( + 'java', + [ + '-jar', + p.join(workingDirectory.path, 'bundletool.jar'), + 'build-apks', + '--overwrite', + '--bundle=$appBundlePath', + '--output=$output', + ], + environment: { + 'ANDROID_HOME': androidSdkPath, + 'JAVA_HOME': javaHome, + }, + ), + ).called(1); + }); + }); }); group('installApks', () {