feat(shorebird_cli): prepare for Flutter 3.44 libapp.so strip handover (#3758)

This commit is contained in:
Brandon DeRosier
2026-05-19 13:28:36 -07:00
committed by GitHub
parent 96bde1b5f7
commit ecf79d80a7
25 changed files with 790 additions and 18 deletions
+12
View File
@@ -122,6 +122,7 @@ final engineArtifactPatterns = {
/// Patterns for Flutter artifacts which don't depend on an engine revision.
final flutterArtifactPatterns = {
// Pre-3.44 layout: ios-usb-dependencies/<artifact>/<hash>/<artifact>.zip
r'flutter_infra_release\/ios-usb-dependencies\/usbmuxd\/(.*)\/usbmuxd\.zip',
r'flutter_infra_release\/ios-usb-dependencies\/libusbmuxd\/(.*)\/libusbmuxd\.zip',
r'flutter_infra_release\/ios-usb-dependencies\/openssl\/(.*)\/openssl\.zip',
@@ -129,6 +130,17 @@ final flutterArtifactPatterns = {
r'flutter_infra_release\/ios-usb-dependencies\/libimobiledevice\/(.*)\/libimobiledevice\.zip',
r'flutter_infra_release\/ios-usb-dependencies\/libimobiledeviceglue\/(.*)\/libimobiledeviceglue\.zip',
r'flutter_infra_release\/ios-usb-dependencies\/ios-deploy\/(.*)\/ios-deploy\.zip',
// 3.44+ layout: ios-usb-dependencies/arm64_x86_64/<artifact>/<hash>/<artifact>.zip
// Flutter 3.44 (flutter/flutter#181539 + related) reorganized iOS USB
// dependency URLs to namespace by architecture. Upstream GCS serves both
// layouts; we list both so customers on either Flutter version resolve.
r'flutter_infra_release\/ios-usb-dependencies\/arm64_x86_64\/usbmuxd\/(.*)\/usbmuxd\.zip',
r'flutter_infra_release\/ios-usb-dependencies\/arm64_x86_64\/libusbmuxd\/(.*)\/libusbmuxd\.zip',
r'flutter_infra_release\/ios-usb-dependencies\/arm64_x86_64\/openssl\/(.*)\/openssl\.zip',
r'flutter_infra_release\/ios-usb-dependencies\/arm64_x86_64\/libplist\/(.*)\/libplist\.zip',
r'flutter_infra_release\/ios-usb-dependencies\/arm64_x86_64\/libimobiledevice\/(.*)\/libimobiledevice\.zip',
r'flutter_infra_release\/ios-usb-dependencies\/arm64_x86_64\/libimobiledeviceglue\/(.*)\/libimobiledeviceglue\.zip',
r'flutter_infra_release\/ios-usb-dependencies\/arm64_x86_64\/ios-deploy\/(.*)\/ios-deploy\.zip',
r'flutter_infra_release\/gradle-wrapper\/(.*)\/gradle-wrapper\.tgz',
r'flutter_infra_release\/flutter\/fonts\/(.*)\/fonts\.zip',
r'flutter_infra_release\/cipd\/flutter\/web\/canvaskit_bundle\/\+\/(.*)',
@@ -462,10 +462,27 @@ Building with Flutter $flutterVersionString to determine the release version...
'--obfuscate',
'--extra-gen-snapshot-options='
'--load-obfuscation-map=${obfuscationMapFile.path}',
// Strip unobfuscated DWARF debug info from the compiled snapshot so
// it doesn't leak identifiers that obfuscation was meant to hide.
'--extra-gen-snapshot-options=--strip',
]);
// Gate --strip on the release's Flutter revision (not the user's
// currently-installed pin) so the patch's gen_snapshot behavior
// matches the release's. On Android with Flutter 3.44+ AGP performs
// the strip; passing --strip here would pre-strip the snapshot,
// leaving AGP nothing to strip and tripping flutter_tools'
// post-build "libapp.so.sym or libapp.so.dbg not present" check.
final shouldPreStripInGenSnapshot = await shorebirdFlutter
.shouldPreStripLibappInGenSnapshot(
platform: patcher.releaseType.releasePlatform,
flutterRevision: release.flutterRevision,
);
if (shouldPreStripInGenSnapshot) {
// Strip unobfuscated DWARF debug info from the compiled snapshot
// so it doesn't leak identifiers that obfuscation was meant to
// hide. On Android 3.44+ this is handled by AGP instead; see the
// block above.
extraBuildArgs.add('--extra-gen-snapshot-options=--strip');
}
}
// Flutter requires --split-debug-info with --obfuscate. Auto-add it
// if --obfuscate will be in the build args (from the user or from
@@ -85,7 +85,7 @@ class AarReleaser extends Releaser {
final base64PublicKey = await getEncodedPublicKey();
final buildArgs = [...argResults.forwardedArgs];
addSplitDebugInfoDefault(buildArgs);
addObfuscationMapArgs(buildArgs);
await addObfuscationMapArgs(buildArgs);
await artifactBuilder.buildAar(
buildNumber: buildNumber,
targetPlatforms: architectures,
@@ -113,7 +113,7 @@ Please comment and upvote ${link(uri: Uri.parse('https://github.com/shorebirdtec
final base64PublicKey = await getEncodedPublicKey();
final buildArgs = [...argResults.forwardedArgs];
addSplitDebugInfoDefault(buildArgs);
addObfuscationMapArgs(buildArgs);
await addObfuscationMapArgs(buildArgs);
final aab = await artifactBuilder.buildAppBundle(
flavor: flavor,
target: target,
@@ -76,7 +76,7 @@ class IosFrameworkReleaser extends Releaser with AppleReleaserMixin {
final base64PublicKey = await getEncodedPublicKey();
final buildArgs = [...argResults.forwardedArgs];
addSplitDebugInfoDefault(buildArgs);
addObfuscationMapArgs(buildArgs);
await addObfuscationMapArgs(buildArgs);
await artifactBuilder.buildIosFramework(
args: buildArgs,
base64PublicKey: base64PublicKey,
@@ -101,7 +101,7 @@ If left checked, Xcode will rewrite the build number in the uploaded IPA, so the
final buildArgs = [...argResults.forwardedArgs];
addSplitDebugInfoDefault(buildArgs);
addObfuscationMapArgs(buildArgs);
await addObfuscationMapArgs(buildArgs);
await artifactBuilder.buildIpa(
codesign: codesign,
@@ -75,7 +75,7 @@ To change the version of this release, change your app's version in your pubspec
final base64PublicKey = await getEncodedPublicKey();
final buildArgs = [...argResults.forwardedArgs];
addSplitDebugInfoDefault(buildArgs);
addObfuscationMapArgs(buildArgs);
await addObfuscationMapArgs(buildArgs);
await artifactBuilder.buildLinuxApp(
target: target,
args: buildArgs,
@@ -71,7 +71,7 @@ class MacosReleaser extends Releaser with AppleReleaserMixin {
final buildArgs = [...argResults.forwardedArgs];
addSplitDebugInfoDefault(buildArgs);
addObfuscationMapArgs(buildArgs);
await addObfuscationMapArgs(buildArgs);
await artifactBuilder.buildMacos(
codesign: codesign,
@@ -144,18 +144,49 @@ abstract class Releaser {
/// Adds obfuscation-related gen_snapshot options to [buildArgs].
///
/// When obfuscation is enabled, passes --save-obfuscation-map to capture the
/// mapping and --strip to remove unobfuscated DWARF debugging information
/// from the compiled snapshot (the DWARF sections would otherwise leak
/// identifiers that obfuscation was meant to hide).
void addObfuscationMapArgs(List<String> buildArgs) {
/// When obfuscation is enabled, passes --save-obfuscation-map to capture
/// the mapping. Conditionally passes --strip to remove unobfuscated DWARF
/// debugging information from the compiled snapshot, since the DWARF
/// sections would otherwise leak identifiers that obfuscation was meant
/// to hide.
///
/// On Android, --strip is only passed for Flutter versions older than
/// 3.44. From 3.44 onward, upstream Flutter PR
/// https://github.com/flutter/flutter/pull/181275 (merged 2026-01-26)
/// made AGP responsible for stripping `libapp.so` and emitting the
/// matching `.sym` companion into the AAB's BUNDLE-METADATA. Passing
/// --strip to gen_snapshot on 3.44+ pre-strips the snapshot, leaving AGP
/// with nothing to strip. flutter_tools then fails the build with
/// "libapp.so.sym or libapp.so.dbg not present when checking final
/// appbundle for debug symbols." Letting AGP do the stripping preserves
/// the obfuscation protection in the user-shipped APK (AGP uses
/// `llvm-strip --strip-unneeded`, which removes the same DWARF sections
/// that gen_snapshot --strip would have), while restoring the `.sym`
/// companion that 3.44 requires. The `.sym` file containing the
/// pre-strip DWARF is only retained in BUNDLE-METADATA, which Play
/// strips before delivery to end-user devices, so the only readers are
/// the developer's own Play Console crash dashboard.
///
/// On non-Android platforms (iOS, macOS, Linux, Windows, iOS framework,
/// AAR), AGP is not in the pipeline, so --strip is always passed
/// regardless of Flutter version.
Future<void> addObfuscationMapArgs(List<String> buildArgs) async {
if (!useObfuscation) return;
final mapDir = Directory(p.dirname(obfuscationMapPath));
if (!mapDir.existsSync()) mapDir.createSync(recursive: true);
buildArgs.addAll([
buildArgs.add(
'--extra-gen-snapshot-options=--save-obfuscation-map=$obfuscationMapPath',
'--extra-gen-snapshot-options=--strip',
]);
);
final shouldPreStripInGenSnapshot = await shorebirdFlutter
.shouldPreStripLibappInGenSnapshot(
platform: releaseType.releasePlatform,
flutterRevision: shorebirdEnv.flutterRevision,
);
if (shouldPreStripInGenSnapshot) {
buildArgs.add('--extra-gen-snapshot-options=--strip');
}
}
/// Platform subdirectory for the supplement directory (e.g. 'android',
@@ -78,7 +78,7 @@ To change the version of this release, change your app's version in your pubspec
final base64PublicKey = await getEncodedPublicKey();
final buildArgs = [...argResults.forwardedArgs];
addSplitDebugInfoDefault(buildArgs);
addObfuscationMapArgs(buildArgs);
await addObfuscationMapArgs(buildArgs);
final result = await artifactBuilder.buildWindowsApp(
target: target,
args: buildArgs,
@@ -19,6 +19,7 @@ class Doctor {
/// Validators that verify shorebird will work on Android.
final List<Validator> androidCommandValidators = [
AndroidInternetPermissionValidator(),
LegacyKeepDebugSymbolsValidator(),
];
/// Validators that verify shorebird will work on iOS.
@@ -41,6 +42,7 @@ class Doctor {
List<Validator> initAndDoctorValidators = [
ShorebirdVersionValidator(),
AndroidInternetPermissionValidator(),
LegacyKeepDebugSymbolsValidator(),
XcodeprojFlutterOverrideValidator(),
MacosEntitlementsValidator(),
ShorebirdYamlAssetValidator(),
@@ -93,3 +93,20 @@ final buildTraceSupportConstraint = FlutterSupportConstraint(
'3b10eecea184bb381f1045a878eeff36548ed11e',
},
);
/// Flutter versions where the Android Gradle Plugin (AGP) is the entity
/// responsible for stripping `libapp.so` and emitting the matching
/// `libapp.so.sym` companion into the AAB's BUNDLE-METADATA.
///
/// Background: upstream Flutter PR
/// https://github.com/flutter/flutter/pull/181275 (merged 2026-01-26, first
/// shipped in 3.44) inverted the strip responsibility. Before 3.44, Flutter
/// stripped `libapp.so` itself before bundling. From 3.44 onward, Flutter
/// leaves debug symbols in `libapp.so` and expects AGP's
/// `stripReleaseDebugSymbols` task to strip them and produce a `.sym`
/// companion file used by Play Console for native crash symbolication.
/// flutter_tools adds a post-build verification that fatal-errors when the
/// `.sym` companion is missing.
final libappStrippedByAgpConstraint = FlutterSupportConstraint(
minVersion: Version(3, 44, 0),
);
@@ -7,10 +7,12 @@ import 'package:pub_semver/pub_semver.dart';
import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/executables/executables.dart';
import 'package:shorebird_cli/src/extensions/version.dart';
import 'package:shorebird_cli/src/flutter_version_constraints.dart';
import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_cli/src/platform.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_process.dart';
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
/// A reference to a [ShorebirdFlutter] instance.
final shorebirdFlutterRef = create(ShorebirdFlutter.new);
@@ -268,6 +270,33 @@ class ShorebirdFlutter {
}
}
/// Whether `gen_snapshot` should be invoked with `--strip` for a build
/// targeting [platform] on the Flutter pin identified by [flutterRevision].
///
/// On non-Android platforms (iOS, macOS, Linux, Windows, iOS framework,
/// AAR), AGP is not in the pipeline, so we always pre-strip in gen_snapshot.
///
/// On Android, the answer depends on the Flutter version: from 3.44 onward
/// AGP performs the strip and emits the matching `.sym` companion;
/// pre-stripping in gen_snapshot on those versions leaves AGP with nothing
/// to strip and trips flutter_tools' post-build verification. See
/// [libappStrippedByAgpConstraint].
///
/// An unresolvable [flutterRevision] (e.g. a development branch) is treated
/// as satisfying the constraint, since the alternative — pre-stripping —
/// would fail the post-build check on any 3.44+ pin.
Future<bool> shouldPreStripLibappInGenSnapshot({
required ReleasePlatform platform,
required String flutterRevision,
}) async {
if (platform != ReleasePlatform.android) return true;
final version = await resolveFlutterVersion(flutterRevision);
return !libappStrippedByAgpConstraint.isSatisfiedBy(
version: version ?? libappStrippedByAgpConstraint.minVersion,
revision: flutterRevision,
);
}
/// Fetches the latest remote refs for the Flutter clone so that
/// release branch pointers (e.g. `flutter_release/3.38.5`) are up to date.
Future<void> fetchRemoteRefs() async {
@@ -0,0 +1,114 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/flutter_version_constraints.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
import 'package:shorebird_cli/src/validators/validators.dart';
/// Warns when `android/app/build.gradle.kts` (or its Groovy sibling) contains
/// a legacy `packaging.jniLibs.keepDebugSymbols.add("**/libapp.so")` line on
/// projects that target Flutter 3.44 or newer.
///
/// Background:
///
/// Upstream Flutter PR https://github.com/flutter/flutter/pull/181275 (merged
/// 2026-01-26, first shipped in 3.44) inverted the responsibility for
/// stripping `libapp.so`. Before 3.44 Flutter stripped the AOT shared library
/// itself, so users (and the e2e test fixture) added the AGP-level
/// `keepDebugSymbols` exclusion to keep `libapp.so` byte-stable for Shorebird
/// patch diffing. From 3.44 onward, Flutter expects the Android Gradle Plugin
/// (AGP) to strip the library and produce the matching `libapp.so.sym`
/// companion in the AAB's BUNDLE-METADATA, and flutter_tools adds a
/// post-build verification that fatal-errors when that companion is missing.
///
/// The legacy line tells AGP to skip stripping `libapp.so`, which prevents
/// the `.sym` from being produced and causes the new verification to fail.
/// On 3.44+ the line is unnecessary as well as actively harmful, so we surface
/// a warning that points the user at the exact file and substring to remove.
///
/// On older Flutter versions the line is still appropriate and this validator
/// is a no-op.
class LegacyKeepDebugSymbolsValidator extends Validator {
/// The substring we treat as a match. Catches `add(...)` and `+=` forms,
/// single-quoted and double-quoted, with arbitrary whitespace around the
/// `(` or `+=`, since users may have hand-edited the line.
static final _legacyKeepDebugSymbolsPattern = RegExp(
r'''keepDebugSymbols\s*(?:\.add\s*\(|\+=)\s*['"][^'"]*\*\*/libapp\.so['"]''',
);
@override
String get description =>
'android/app/build.gradle(.kts) does not contain a legacy '
'keepDebugSymbols line for libapp.so';
@override
bool canRunInCurrentContext() {
if (_androidAppDirectory == null) return false;
return _gradleFiles.any((f) => f.existsSync());
}
// coverage:ignore-start
@override
String? get incorrectContextMessage =>
'No android/app directory was found, so this validator does not apply.';
// coverage:ignore-end
@override
Future<List<ValidationIssue>> validate() async {
final flutterRevision = shorebirdEnv.flutterRevision;
final flutterVersion = await shorebirdFlutter.resolveFlutterVersion(
flutterRevision,
);
final agpStripsLibapp = libappStrippedByAgpConstraint.isSatisfiedBy(
// Treat unknown versions as satisfying the constraint so users who
// are pinned to a development revision (where resolveFlutterVersion
// returns null) still get the migration nudge. False positives on
// unrelated dev branches are preferable to silently missing a real
// breakage on the upgrade path.
version: flutterVersion ?? libappStrippedByAgpConstraint.minVersion,
revision: flutterRevision,
);
if (!agpStripsLibapp) return [];
final issues = <ValidationIssue>[];
for (final gradleFile in _gradleFiles) {
if (!gradleFile.existsSync()) continue;
final contents = gradleFile.readAsStringSync();
if (!_legacyKeepDebugSymbolsPattern.hasMatch(contents)) continue;
final relativePath = p.relative(
gradleFile.path,
from: shorebirdEnv.getFlutterProjectRoot()!.path,
);
issues.add(
ValidationIssue(
severity: ValidationIssueSeverity.warning,
message:
'$relativePath contains a legacy '
'`packaging.jniLibs.keepDebugSymbols.add("**/libapp.so")` '
'line. Flutter 3.44 (PR flutter/flutter#181275) requires '
'AGP to strip libapp.so and emit a `libapp.so.sym` '
'companion. The legacy line blocks AGP from stripping, '
'which causes builds to fail or skips Play Console crash '
'symbols for Dart code. Remove the line.',
),
);
}
return issues;
}
Directory? get _androidAppDirectory {
final root = shorebirdEnv.getFlutterProjectRoot();
if (root == null) return null;
return Directory(p.join(root.path, 'android', 'app'));
}
List<File> get _gradleFiles {
final appDir = _androidAppDirectory;
if (appDir == null) return const [];
return [
File(p.join(appDir.path, 'build.gradle.kts')),
File(p.join(appDir.path, 'build.gradle')),
];
}
}
@@ -6,6 +6,7 @@ import 'package:shorebird_cli/src/shorebird_process.dart';
export 'android_internet_permission_validator.dart';
export 'flavor_validator.dart';
export 'legacy_keep_debug_symbols_validator.dart';
export 'macos_network_entitlement_validator.dart';
export 'shorebird_version_validator.dart';
export 'shorebird_yaml_asset_validator.dart';
@@ -588,6 +588,76 @@ void main() {
).called(1);
});
});
group('when the supplement contains an obfuscation map', () {
// Wire up extractZip to actually write the obfuscation_map.json
// so PatchCommand's `if (obfuscationMapFile != null)` block
// fires. The flutter-version gating that drops --strip on
// Android 3.44+ depends on `release.flutterRevision` resolving
// to a Version, so we mock that per-test.
setUp(() {
when(
() => artifactManager.extractZip(
zipFile: any(named: 'zipFile'),
outputDirectory: any(named: 'outputDirectory'),
),
).thenAnswer((invocation) async {
final outputDirectory =
invocation.namedArguments[#outputDirectory] as Directory;
File(
p.join(outputDirectory.path, 'obfuscation_map.json'),
).writeAsStringSync('{}');
});
});
test(
'''on Android with Flutter < 3.44, passes --strip in extraBuildArgs''',
() async {
when(
() => shorebirdFlutter.shouldPreStripLibappInGenSnapshot(
platform: any(named: 'platform'),
flutterRevision: any(named: 'flutterRevision'),
),
).thenAnswer((_) async => true);
await runWithOverrides(() => command.createPatch(patcher));
final captured =
verify(
() => patcher.extraBuildArgs = captureAny(),
).captured.last
as List<String>;
expect(
captured,
contains('--extra-gen-snapshot-options=--strip'),
);
},
);
test(
'''on Android with Flutter 3.44+, omits --strip in extraBuildArgs''',
() async {
when(
() => shorebirdFlutter.shouldPreStripLibappInGenSnapshot(
platform: any(named: 'platform'),
flutterRevision: any(named: 'flutterRevision'),
),
).thenAnswer((_) async => false);
await runWithOverrides(() => command.createPatch(patcher));
final captured =
verify(
() => patcher.extraBuildArgs = captureAny(),
).captured.last
as List<String>;
expect(
captured,
isNot(contains('--extra-gen-snapshot-options=--strip')),
);
},
);
});
});
group(
@@ -479,6 +479,19 @@ void main() {
setUp(() {
when(() => argResults['obfuscate']).thenReturn(true);
when(() => argResults.wasParsed('obfuscate')).thenReturn(true);
when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef');
when(
() => shorebirdFlutter.resolveFlutterVersion(any()),
).thenAnswer((_) async => Version(3, 41, 2));
// AAR is an Android pipeline target; addObfuscationMapArgs
// consults this helper to decide whether to pass --strip.
// On pre-3.44 Flutter we still pre-strip in gen_snapshot.
when(
() => shorebirdFlutter.shouldPreStripLibappInGenSnapshot(
platform: any(named: 'platform'),
flutterRevision: any(named: 'flutterRevision'),
),
).thenAnswer((_) async => true);
// Simulate the build creating the obfuscation map.
when(
() => artifactBuilder.buildAar(
@@ -342,6 +342,89 @@ To change the version of this release, change your app's version in your pubspec
});
});
group('addObfuscationMapArgs', () {
// Flutter PR https://github.com/flutter/flutter/pull/181275 (3.44)
// moved libapp.so stripping responsibility from gen_snapshot to AGP.
// Passing --strip on 3.44+ pre-strips the snapshot, blocks AGP from
// emitting the `.sym` companion, and trips flutter_tools' new
// post-build verification. The releaser must therefore conditionally
// omit --strip on Android for 3.44+ pins.
setUp(() {
when(() => argResults['obfuscate']).thenReturn(true);
when(() => argResults.wasParsed('obfuscate')).thenReturn(true);
when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef');
});
test(
'on Flutter < 3.44, passes --strip so gen_snapshot strips libapp.so',
() async {
when(
() => shorebirdFlutter.shouldPreStripLibappInGenSnapshot(
platform: any(named: 'platform'),
flutterRevision: any(named: 'flutterRevision'),
),
).thenAnswer((_) async => true);
final buildArgs = <String>[];
await runWithOverrides(
() => androidReleaser.addObfuscationMapArgs(buildArgs),
);
expect(
buildArgs,
containsAll([
startsWith(
'--extra-gen-snapshot-options=--save-obfuscation-map=',
),
'--extra-gen-snapshot-options=--strip',
]),
);
},
);
test(
'''on Flutter 3.44+ Android, omits --strip so AGP can strip libapp.so and emit libapp.so.sym''',
() async {
when(
() => shorebirdFlutter.shouldPreStripLibappInGenSnapshot(
platform: any(named: 'platform'),
flutterRevision: any(named: 'flutterRevision'),
),
).thenAnswer((_) async => false);
final buildArgs = <String>[];
await runWithOverrides(
() => androidReleaser.addObfuscationMapArgs(buildArgs),
);
expect(
buildArgs,
contains(
startsWith(
'--extra-gen-snapshot-options=--save-obfuscation-map=',
),
),
);
expect(
buildArgs,
isNot(contains('--extra-gen-snapshot-options=--strip')),
);
},
);
test('is a no-op when --obfuscate is not set', () async {
when(() => argResults['obfuscate']).thenReturn(false);
when(() => argResults.wasParsed('obfuscate')).thenReturn(false);
final buildArgs = <String>[];
await runWithOverrides(
() => androidReleaser.addObfuscationMapArgs(buildArgs),
);
expect(buildArgs, isEmpty);
});
});
group('buildReleaseArtifacts', () {
late File aabFile;
@@ -485,6 +485,14 @@ void main() {
setUp(() {
when(() => argResults['obfuscate']).thenReturn(true);
when(() => argResults.wasParsed('obfuscate')).thenReturn(true);
when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef');
// Non-Android pipelines always pre-strip in gen_snapshot.
when(
() => shorebirdFlutter.shouldPreStripLibappInGenSnapshot(
platform: any(named: 'platform'),
flutterRevision: any(named: 'flutterRevision'),
),
).thenAnswer((_) async => true);
// Simulate the build creating the obfuscation map.
when(
() => artifactBuilder.buildIosFramework(
@@ -358,6 +358,41 @@ $body
});
});
group('addObfuscationMapArgs', () {
// The libapp.so strip gating only applies to Android; on iOS AGP is
// not in the pipeline. iOS must continue to pre-strip the snapshot in
// gen_snapshot regardless of the Flutter version to prevent the
// DWARF debug sections from leaking the identifiers obfuscation is
// meant to hide.
setUp(() {
when(() => argResults['obfuscate']).thenReturn(true);
when(() => argResults.wasParsed('obfuscate')).thenReturn(true);
when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef');
when(
() => shorebirdEnv.getShorebirdProjectRoot(),
).thenReturn(projectRoot);
});
test('passes --strip on Flutter 3.44+ for iOS', () async {
when(
() => shorebirdFlutter.shouldPreStripLibappInGenSnapshot(
platform: any(named: 'platform'),
flutterRevision: any(named: 'flutterRevision'),
),
).thenAnswer((_) async => true);
final buildArgs = <String>[];
await runWithOverrides(
() => iosReleaser.addObfuscationMapArgs(buildArgs),
);
expect(
buildArgs,
contains('--extra-gen-snapshot-options=--strip'),
);
});
});
group('buildReleaseArtifacts', () {
const flutterVersionAndRevision = '3.10.6 (83305b5088)';
const base64PublicKey = 'base64PublicKey';
@@ -629,6 +664,14 @@ $body
setUp(() {
when(() => argResults['obfuscate']).thenReturn(true);
when(() => argResults.wasParsed('obfuscate')).thenReturn(true);
when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef');
// iOS always pre-strips in gen_snapshot (AGP isn't in the pipeline).
when(
() => shorebirdFlutter.shouldPreStripLibappInGenSnapshot(
platform: any(named: 'platform'),
flutterRevision: any(named: 'flutterRevision'),
),
).thenAnswer((_) async => true);
// By default, simulate the build creating the obfuscation map.
when(
() => artifactBuilder.buildIpa(
@@ -410,6 +410,14 @@ To change the version of this release, change your app's version in your pubspec
setUp(() {
when(() => argResults['obfuscate']).thenReturn(true);
when(() => argResults.wasParsed('obfuscate')).thenReturn(true);
when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef');
// Non-Android pipelines always pre-strip in gen_snapshot.
when(
() => shorebirdFlutter.shouldPreStripLibappInGenSnapshot(
platform: any(named: 'platform'),
flutterRevision: any(named: 'flutterRevision'),
),
).thenAnswer((_) async => true);
// Simulate the build creating the obfuscation map.
when(
() => artifactBuilder.buildLinuxApp(
@@ -68,6 +68,10 @@ void main() {
);
}
setUpAll(() {
registerFallbackValue(ReleasePlatform.macos);
});
setUp(() {
argResults = MockArgResults();
artifactBuilder = MockArtifactBuilder();
@@ -455,6 +459,14 @@ To change the version of this release, change your app's version in your pubspec
setUp(() {
when(() => argResults['obfuscate']).thenReturn(true);
when(() => argResults.wasParsed('obfuscate')).thenReturn(true);
when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef');
// Non-Android pipelines always pre-strip in gen_snapshot.
when(
() => shorebirdFlutter.shouldPreStripLibappInGenSnapshot(
platform: any(named: 'platform'),
flutterRevision: any(named: 'flutterRevision'),
),
).thenAnswer((_) async => true);
// By default, simulate the build creating the obfuscation map.
when(
() => artifactBuilder.buildMacos(
@@ -426,6 +426,14 @@ To change the version of this release, change your app's version in your pubspec
setUp(() {
when(() => argResults['obfuscate']).thenReturn(true);
when(() => argResults.wasParsed('obfuscate')).thenReturn(true);
when(() => shorebirdEnv.flutterRevision).thenReturn('deadbeef');
// Non-Android pipelines always pre-strip in gen_snapshot.
when(
() => shorebirdFlutter.shouldPreStripLibappInGenSnapshot(
platform: any(named: 'platform'),
flutterRevision: any(named: 'flutterRevision'),
),
).thenAnswer((_) async => true);
// Simulate the build creating the obfuscation map.
when(
() => artifactBuilder.buildWindowsApp(
@@ -13,6 +13,7 @@ import 'package:shorebird_cli/src/platform.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';
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
import 'mocks.dart';
@@ -455,6 +456,90 @@ Tools • Dart 3.0.6 • DevTools 2.23.1''');
});
});
group('shouldPreStripLibappInGenSnapshot', () {
test('returns true on iOS regardless of Flutter version', () async {
when(
() => git.forEachRef(
directory: any(named: 'directory'),
contains: any(named: 'contains'),
format: any(named: 'format'),
pattern: any(named: 'pattern'),
),
).thenAnswer((_) async => 'origin/flutter_release/3.44.0');
final result = await runWithOverrides(
() => shorebirdFlutter.shouldPreStripLibappInGenSnapshot(
platform: ReleasePlatform.ios,
flutterRevision: 'deadbeef',
),
);
expect(result, isTrue);
});
test('returns true on Android when Flutter is older than 3.44', () async {
when(
() => git.forEachRef(
directory: any(named: 'directory'),
contains: any(named: 'contains'),
format: any(named: 'format'),
pattern: any(named: 'pattern'),
),
).thenAnswer((_) async => 'origin/flutter_release/3.43.0');
final result = await runWithOverrides(
() => shorebirdFlutter.shouldPreStripLibappInGenSnapshot(
platform: ReleasePlatform.android,
flutterRevision: 'deadbeef',
),
);
expect(result, isTrue);
});
test('returns false on Android when Flutter is 3.44 or newer', () async {
when(
() => git.forEachRef(
directory: any(named: 'directory'),
contains: any(named: 'contains'),
format: any(named: 'format'),
pattern: any(named: 'pattern'),
),
).thenAnswer((_) async => 'origin/flutter_release/3.44.0');
final result = await runWithOverrides(
() => shorebirdFlutter.shouldPreStripLibappInGenSnapshot(
platform: ReleasePlatform.android,
flutterRevision: 'deadbeef',
),
);
expect(result, isFalse);
});
test(
'''returns false on Android when the version cannot be resolved (development pin)''',
() async {
// Unresolvable revisions (e.g. development branches) fall back to the
// constraint's min version, so users on bleeding-edge pins get the
// 3.44+ AGP-stripped behavior rather than the pre-strip path.
when(
() => git.forEachRef(
directory: any(named: 'directory'),
contains: any(named: 'contains'),
format: any(named: 'format'),
pattern: any(named: 'pattern'),
),
).thenAnswer((_) async => '');
final result = await runWithOverrides(
() => shorebirdFlutter.shouldPreStripLibappInGenSnapshot(
platform: ReleasePlatform.android,
flutterRevision: 'deadbeef',
),
);
expect(result, isFalse);
},
);
});
group('fetchRemoteRefs', () {
test('fetches from remote', () async {
when(
@@ -0,0 +1,219 @@
import 'dart:io';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as p;
import 'package:pub_semver/pub_semver.dart';
import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
import 'package:shorebird_cli/src/validators/validators.dart';
import 'package:test/test.dart';
import '../mocks.dart';
void main() {
group(LegacyKeepDebugSymbolsValidator, () {
const flutterRevision = 'aaaa1111bbbb2222cccc3333dddd4444eeee5555';
late Directory projectRoot;
late ShorebirdEnv shorebirdEnv;
late ShorebirdFlutter shorebirdFlutter;
String kotlinGradleWithLegacyLine = '''
android {
buildTypes {
release {
packaging.jniLibs.keepDebugSymbols.add("**/libapp.so")
}
}
}
''';
String groovyGradleWithLegacyPlusEquals = '''
android {
buildTypes {
release {
packaging.jniLibs.keepDebugSymbols += '**/libapp.so'
}
}
}
''';
String cleanGradle = '''
android {
buildTypes {
release {
// Nothing about libapp.so here.
}
}
}
''';
void writeGradle(String filename, String contents) {
final appDir = Directory(p.join(projectRoot.path, 'android', 'app'))
..createSync(recursive: true);
File(p.join(appDir.path, filename)).writeAsStringSync(contents);
}
R runWithOverrides<R>(R Function() body) {
return runScoped(
body,
values: {
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
shorebirdFlutterRef.overrideWith(() => shorebirdFlutter),
},
);
}
setUp(() {
projectRoot = Directory.systemTemp.createTempSync();
shorebirdEnv = MockShorebirdEnv();
shorebirdFlutter = MockShorebirdFlutter();
when(() => shorebirdEnv.getFlutterProjectRoot()).thenReturn(projectRoot);
when(() => shorebirdEnv.flutterRevision).thenReturn(flutterRevision);
when(
() => shorebirdFlutter.resolveFlutterVersion(any()),
).thenAnswer((_) async => Version(3, 44, 0));
});
test('has a non-empty description', () {
expect(LegacyKeepDebugSymbolsValidator().description, isNotEmpty);
});
group('canRunInCurrentContext', () {
test('returns false when no android/app directory exists', () {
expect(
runWithOverrides(
() => LegacyKeepDebugSymbolsValidator().canRunInCurrentContext(),
),
isFalse,
);
});
test('returns true when build.gradle.kts exists', () {
writeGradle('build.gradle.kts', cleanGradle);
expect(
runWithOverrides(
() => LegacyKeepDebugSymbolsValidator().canRunInCurrentContext(),
),
isTrue,
);
});
test('returns true when only build.gradle (Groovy) exists', () {
writeGradle('build.gradle', cleanGradle);
expect(
runWithOverrides(
() => LegacyKeepDebugSymbolsValidator().canRunInCurrentContext(),
),
isTrue,
);
});
});
group('on Flutter < 3.44', () {
setUp(() {
// The keepDebugSymbols line was the intended configuration before
// 3.44, so the validator must be a no-op on older Flutter to avoid
// false-positive warnings.
when(
() => shorebirdFlutter.resolveFlutterVersion(any()),
).thenAnswer((_) async => Version(3, 41, 9));
});
test('returns no issues even when the legacy line is present', () async {
writeGradle('build.gradle.kts', kotlinGradleWithLegacyLine);
final issues = await runWithOverrides(
LegacyKeepDebugSymbolsValidator().validate,
);
expect(issues, isEmpty);
});
});
group('on Flutter >= 3.44', () {
test(
'returns no issues when neither gradle file contains the line',
() async {
writeGradle('build.gradle.kts', cleanGradle);
final issues = await runWithOverrides(
LegacyKeepDebugSymbolsValidator().validate,
);
expect(issues, isEmpty);
},
);
test(
'returns a warning when build.gradle.kts has the .add(...) form',
() async {
writeGradle('build.gradle.kts', kotlinGradleWithLegacyLine);
final issues = await runWithOverrides(
LegacyKeepDebugSymbolsValidator().validate,
);
expect(issues, hasLength(1));
final issue = issues.single;
expect(issue.severity, ValidationIssueSeverity.warning);
expect(issue.message, contains('build.gradle.kts'));
expect(issue.message, contains('keepDebugSymbols'));
expect(issue.message, contains('libapp.so'));
expect(issue.message, contains('flutter/flutter#181275'));
},
);
test('returns a warning when build.gradle has the += form', () async {
writeGradle('build.gradle', groovyGradleWithLegacyPlusEquals);
final issues = await runWithOverrides(
LegacyKeepDebugSymbolsValidator().validate,
);
expect(issues, hasLength(1));
expect(issues.single.message, contains('build.gradle'));
});
test(
'returns two warnings when both files contain the legacy line',
() async {
writeGradle('build.gradle.kts', kotlinGradleWithLegacyLine);
writeGradle('build.gradle', groovyGradleWithLegacyPlusEquals);
final issues = await runWithOverrides(
LegacyKeepDebugSymbolsValidator().validate,
);
expect(issues, hasLength(2));
expect(
issues.map((i) => i.severity),
everyElement(ValidationIssueSeverity.warning),
);
},
);
});
test(
'treats an unknown Flutter version as satisfying the constraint',
() async {
// resolveFlutterVersion returns null for development pins. The
// validator should still surface the warning in that case rather
// than silently skipping; users on bleeding-edge pins are exactly
// the ones most likely to be on a 3.44-equivalent fork.
when(
() => shorebirdFlutter.resolveFlutterVersion(any()),
).thenAnswer((_) async => null);
writeGradle('build.gradle.kts', kotlinGradleWithLegacyLine);
final issues = await runWithOverrides(
LegacyKeepDebugSymbolsValidator().validate,
);
expect(issues, hasLength(1));
},
);
});
}