Skip to content

Commit

Permalink
feat(shorebird_cli): add release android_archive command (#573)
Browse files Browse the repository at this point in the history
  • Loading branch information
bryanoltman authored May 31, 2023
1 parent 6c7473d commit a1c0bf4
Show file tree
Hide file tree
Showing 6 changed files with 1,002 additions and 3 deletions.
3 changes: 3 additions & 0 deletions packages/shorebird_cli/lib/src/command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
/// Signature for a function which takes a list of bytes and returns a hash.
typedef HashFunction = String Function(List<int> bytes);

/// Signature for a function which takes a path to a zip file.
typedef UnzipFn = Future<void> Function(String zipFilePath, String outputDir);

typedef CodePushClientBuilder = CodePushClient Function({
required http.Client httpClient,
Uri? hostedUri,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export 'release_android_archive_command.dart';
export 'release_android_command.dart';
export 'release_command.dart';
export 'release_ios_command.dart';
Original file line number Diff line number Diff line change
@@ -0,0 +1,310 @@
import 'dart:async';
import 'dart:io';

import 'package:archive/archive_io.dart';
import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/auth_logger_mixin.dart';
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/config/config.dart';
import 'package:shorebird_cli/src/shorebird_build_mixin.dart';
import 'package:shorebird_cli/src/shorebird_config_mixin.dart';
import 'package:shorebird_cli/src/shorebird_create_app_mixin.dart';
import 'package:shorebird_cli/src/shorebird_java_mixin.dart';
import 'package:shorebird_cli/src/shorebird_release_version_mixin.dart';
import 'package:shorebird_cli/src/shorebird_validation_mixin.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';

/// {@template release_android_archive_command}
/// `shorebird release android_archive`
/// Create new Android archive releases.
/// {@endtemplate}
class ReleaseAndroidArchiveCommand extends ShorebirdCommand
with
AuthLoggerMixin,
ShorebirdValidationMixin,
ShorebirdConfigMixin,
ShorebirdBuildMixin,
ShorebirdCreateAppMixin,
ShorebirdJavaMixin,
ShorebirdReleaseVersionMixin {
/// {@macro release_android_archive_command}
ReleaseAndroidArchiveCommand({
required super.logger,
super.auth,
super.buildCodePushClient,
super.validators,
HashFunction? hashFn,
UnzipFn? unzipFn,
}) : _hashFn = hashFn ?? ((m) => sha256.convert(m).toString()),
_unzipFn = unzipFn ?? extractFileToDisk {
argParser
..addOption(
'flavor',
help: 'The product flavor to use when building the app.',
)
// `flutter build aar` defaults to a build number of 1.0, so we do the
// same.
..addOption(
'build-number',
help: 'The build number of the aar',
defaultsTo: '1.0',
)
..addFlag(
'force',
abbr: 'f',
help: 'Release without confirmation if there are no errors.',
negatable: false,
);
}

@override
String get name => 'android_archive';

@override
String get description => '''
Builds and submits your Android archive to Shorebird.
Shorebird saves the compiled Dart code from your application in order to
make smaller updates to your app.
''';

final HashFunction _hashFn;
final UnzipFn _unzipFn;

@override
Future<int> run() async {
if (!isShorebirdInitialized) {
logger.err(
'Shorebird is not initialized. Did you run "shorebird init"?',
);
return ExitCode.config.code;
}

if (!auth.isAuthenticated) {
printNeedsAuthInstructions();
return ExitCode.noUser.code;
}

final validationIssues = await runValidators();
if (validationIssuesContainsError(validationIssues)) {
logValidationFailure(issues: validationIssues);
return ExitCode.config.code;
}

// We know the pubspec exists due to the call to isShorebirdInitialized
// above.
final pubspec = getPubspecYaml()!;
final module = pubspec.flutter?['module'] as Map?;
final androidPackageName = module?['androidPackage'] as String?;
if (androidPackageName == null) {
logger.err('Could not find androidPackage in pubspec.yaml.');
return ExitCode.config.code;
}

final flavor = results['flavor'] as String?;
final buildNumber = results['build-number'] as String;
final buildProgress = logger.progress('Building aar');
try {
await buildAar(buildNumber: buildNumber, flavor: flavor);
} on ProcessException catch (error) {
buildProgress.fail('Failed to build: ${error.message}');
return ExitCode.software.code;
}

buildProgress.complete();

final shorebirdYaml = getShorebirdYaml()!;
final codePushClient = buildCodePushClient(
httpClient: auth.client,
hostedUri: hostedUri,
);

late final List<App> apps;
final fetchAppsProgress = logger.progress('Fetching apps');
try {
apps = (await codePushClient.getApps())
.map((a) => App(id: a.appId, displayName: a.displayName))
.toList();
fetchAppsProgress.complete();
} catch (error) {
fetchAppsProgress.fail('$error');
return ExitCode.software.code;
}

final appId = shorebirdYaml.getAppId(flavor: flavor);
final app = apps.firstWhereOrNull((a) => a.id == appId);
if (app == null) {
logger.err(
'''
Could not find app with id: "$appId".
Did you forget to run "shorebird init"?''',
);
return ExitCode.software.code;
}

final releaseVersion = buildNumber;
const platform = 'android';
final archNames = architectures.keys.map(
(arch) => arch.name,
);
final summary = [
'''📱 App: ${lightCyan.wrap(app.displayName)} ${lightCyan.wrap('(${app.id})')}''',
if (flavor != null) '🍧 Flavor: ${lightCyan.wrap(flavor)}',
'📦 Release Version: ${lightCyan.wrap(releaseVersion)}',
'''🕹️ Platform: ${lightCyan.wrap(platform)} ${lightCyan.wrap('(${archNames.join(', ')})')}''',
];

logger.info('''
${styleBold.wrap(lightGreen.wrap('🚀 Ready to create a new release!'))}
${summary.join('\n')}
''');

final force = results['force'] == true;
final needConfirmation = !force;
if (needConfirmation) {
final confirm = logger.confirm('Would you like to continue?');

if (!confirm) {
logger.info('Aborting.');
return ExitCode.success.code;
}
}

late final List<Release> releases;
final fetchReleasesProgress = logger.progress('Fetching releases');
try {
releases = await codePushClient.getReleases(appId: app.id);
fetchReleasesProgress.complete();
} catch (error) {
fetchReleasesProgress.fail('$error');
return ExitCode.software.code;
}

var release = releases.firstWhereOrNull((r) => r.version == releaseVersion);
if (release == null) {
final flutterRevisionProgress = logger.progress(
'Fetching Flutter revision',
);
final String shorebirdFlutterRevision;
try {
shorebirdFlutterRevision = await getShorebirdFlutterRevision();
flutterRevisionProgress.complete();
} catch (error) {
flutterRevisionProgress.fail('$error');
return ExitCode.software.code;
}

final createReleaseProgress = logger.progress('Creating release');
try {
release = await codePushClient.createRelease(
appId: app.id,
version: releaseVersion,
flutterRevision: shorebirdFlutterRevision,
);
createReleaseProgress.complete();
} catch (error) {
createReleaseProgress.fail('$error');
return ExitCode.software.code;
}
}

final createArtifactProgress = logger.progress('Creating artifacts');
final aarDir = p.joinAll([
'build',
'host',
'outputs',
'repo',
...androidPackageName.split('.'),
'flutter_release',
buildNumber,
]);
final aarPath = p.join(
aarDir,
'flutter_release-$buildNumber.aar',
);
final zipPath = p.join(
aarDir,
'flutter_release-$buildNumber.zip',
);

// Copy the .aar file to a .zip file so package:archive knows how to read it
File(aarPath).copySync(zipPath);
final extractedAarDir = p.join(aarDir, 'flutter_release-$buildNumber');
// Unzip the .zip file to a directory so we can read the .so files
await _unzipFn(zipPath, extractedAarDir);

for (final archMetadata in architectures.values) {
final artifactPath = p.join(
extractedAarDir,
'jni',
archMetadata.path,
'libapp.so',
);
final artifact = File(artifactPath);
final hash = _hashFn(await artifact.readAsBytes());
logger.detail('Creating artifact for $artifactPath');

try {
await codePushClient.createReleaseArtifact(
releaseId: release.id,
artifactPath: artifact.path,
arch: archMetadata.arch,
platform: platform,
hash: hash,
);
} on CodePushConflictException catch (_) {
// Newlines are due to how logger.info interacts with logger.progress.
logger.info(
'''
${archMetadata.arch} artifact already exists, continuing...''',
);
} catch (error) {
createArtifactProgress.fail('Error uploading ${artifact.path}: $error');
return ExitCode.software.code;
}
}

try {
logger.detail('Creating artifact for $aarPath');
await codePushClient.createReleaseArtifact(
releaseId: release.id,
artifactPath: aarPath,
arch: 'aar',
platform: platform,
hash: _hashFn(await File(aarPath).readAsBytes()),
);
} on CodePushConflictException catch (_) {
// Newlines are due to how logger.info interacts with logger.progress.
logger.info(
'''
aar artifact already exists, continuing...''',
);
} catch (error) {
createArtifactProgress.fail('Error uploading $aarPath: $error');
return ExitCode.software.code;
}

createArtifactProgress.complete();

logger
..success('\n✅ Published Release!')
..info('''
Your next step is to add this module as a dependency in your app's build.gradle:
${lightCyan.wrap('''
dependencies {
// ...
releaseImplementation '$androidPackageName:flutter_release:$buildNumber'
// ...
}''')}
''');

return ExitCode.success.code;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:shorebird_cli/src/commands/commands.dart';
class ReleaseCommand extends ShorebirdCommand {
/// {@macro release_command}
ReleaseCommand({required super.logger}) {
addSubcommand(ReleaseAndroidArchiveCommand(logger: logger));
addSubcommand(ReleaseAndroidCommand(logger: logger));
addSubcommand(ReleaseIosCommand(logger: logger));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:io';

import 'package:collection/collection.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/shorebird_process.dart';
import 'package:shorebird_cli/src/validators/validators.dart';
Expand Down Expand Up @@ -27,16 +28,22 @@ class AndroidInternetPermissionValidator extends Validator {
@override
Future<List<ValidationIssue>> validate(ShorebirdProcess process) async {
const manifestFileName = 'AndroidManifest.xml';
final androidSrcDir = Directory(
final androidSrcDir = [
p.join(
Directory.current.path,
'android',
'app',
'src',
),
);
p.join(
Directory.current.path,
'.android',
'Flutter',
'src',
),
].map(Directory.new).firstWhereOrNull((dir) => dir.existsSync());

if (!androidSrcDir.existsSync()) {
if (androidSrcDir == null) {
return [
const ValidationIssue(
severity: ValidationIssueSeverity.error,
Expand Down
Loading

0 comments on commit a1c0bf4

Please sign in to comment.