diff --git a/packages/artifact_proxy/lib/config.dart b/packages/artifact_proxy/lib/config.dart index 17a513928..a4cc9deed 100644 --- a/packages/artifact_proxy/lib/config.dart +++ b/packages/artifact_proxy/lib/config.dart @@ -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///.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///.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\/\+\/(.*)', diff --git a/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart b/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart index 812b4778f..8651bd2b1 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart @@ -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 diff --git a/packages/shorebird_cli/lib/src/commands/release/aar_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/aar_releaser.dart index db822f625..e32e0bac3 100644 --- a/packages/shorebird_cli/lib/src/commands/release/aar_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/aar_releaser.dart @@ -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, diff --git a/packages/shorebird_cli/lib/src/commands/release/android_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/android_releaser.dart index 7ae170ccc..bb24d5457 100644 --- a/packages/shorebird_cli/lib/src/commands/release/android_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/android_releaser.dart @@ -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, diff --git a/packages/shorebird_cli/lib/src/commands/release/ios_framework_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/ios_framework_releaser.dart index 19fdac9aa..4de3e85a6 100644 --- a/packages/shorebird_cli/lib/src/commands/release/ios_framework_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/ios_framework_releaser.dart @@ -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, diff --git a/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart index 1174431c4..4ee3c8d20 100644 --- a/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/ios_releaser.dart @@ -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, diff --git a/packages/shorebird_cli/lib/src/commands/release/linux_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/linux_releaser.dart index dd1626405..67401f1b7 100644 --- a/packages/shorebird_cli/lib/src/commands/release/linux_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/linux_releaser.dart @@ -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, diff --git a/packages/shorebird_cli/lib/src/commands/release/macos_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/macos_releaser.dart index 15e4bd1c8..e544033c1 100644 --- a/packages/shorebird_cli/lib/src/commands/release/macos_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/macos_releaser.dart @@ -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, diff --git a/packages/shorebird_cli/lib/src/commands/release/releaser.dart b/packages/shorebird_cli/lib/src/commands/release/releaser.dart index 61bf55b50..c2912533a 100644 --- a/packages/shorebird_cli/lib/src/commands/release/releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/releaser.dart @@ -121,18 +121,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 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 addObfuscationMapArgs(List 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', diff --git a/packages/shorebird_cli/lib/src/commands/release/windows_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/windows_releaser.dart index c59be0f04..c55dfbcc6 100644 --- a/packages/shorebird_cli/lib/src/commands/release/windows_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/windows_releaser.dart @@ -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, diff --git a/packages/shorebird_cli/lib/src/doctor.dart b/packages/shorebird_cli/lib/src/doctor.dart index 8586ec176..23c4b7009 100644 --- a/packages/shorebird_cli/lib/src/doctor.dart +++ b/packages/shorebird_cli/lib/src/doctor.dart @@ -19,6 +19,7 @@ class Doctor { /// Validators that verify shorebird will work on Android. final List androidCommandValidators = [ AndroidInternetPermissionValidator(), + LegacyKeepDebugSymbolsValidator(), ]; /// Validators that verify shorebird will work on iOS. @@ -41,6 +42,7 @@ class Doctor { List initAndDoctorValidators = [ ShorebirdVersionValidator(), AndroidInternetPermissionValidator(), + LegacyKeepDebugSymbolsValidator(), XcodeprojFlutterOverrideValidator(), MacosEntitlementsValidator(), ShorebirdYamlAssetValidator(), diff --git a/packages/shorebird_cli/lib/src/flutter_version_constraints.dart b/packages/shorebird_cli/lib/src/flutter_version_constraints.dart index 5a44b6a33..6c8f08ca7 100644 --- a/packages/shorebird_cli/lib/src/flutter_version_constraints.dart +++ b/packages/shorebird_cli/lib/src/flutter_version_constraints.dart @@ -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), +); diff --git a/packages/shorebird_cli/lib/src/shorebird_flutter.dart b/packages/shorebird_cli/lib/src/shorebird_flutter.dart index e741403c5..08eae067f 100644 --- a/packages/shorebird_cli/lib/src/shorebird_flutter.dart +++ b/packages/shorebird_cli/lib/src/shorebird_flutter.dart @@ -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 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 fetchRemoteRefs() async { diff --git a/packages/shorebird_cli/lib/src/validators/legacy_keep_debug_symbols_validator.dart b/packages/shorebird_cli/lib/src/validators/legacy_keep_debug_symbols_validator.dart new file mode 100644 index 000000000..f555a57c3 --- /dev/null +++ b/packages/shorebird_cli/lib/src/validators/legacy_keep_debug_symbols_validator.dart @@ -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> 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 = []; + 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 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')), + ]; + } +} diff --git a/packages/shorebird_cli/lib/src/validators/validators.dart b/packages/shorebird_cli/lib/src/validators/validators.dart index 2e10a96a8..654dfb90e 100644 --- a/packages/shorebird_cli/lib/src/validators/validators.dart +++ b/packages/shorebird_cli/lib/src/validators/validators.dart @@ -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'; diff --git a/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart b/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart index 3af45ea06..382c859df 100644 --- a/packages/shorebird_cli/test/src/commands/patch/patch_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/patch_command_test.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; + 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; + expect( + captured, + isNot(contains('--extra-gen-snapshot-options=--strip')), + ); + }, + ); + }); }); group( diff --git a/packages/shorebird_cli/test/src/commands/release/aar_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/aar_releaser_test.dart index e73b96550..4be910c91 100644 --- a/packages/shorebird_cli/test/src/commands/release/aar_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/aar_releaser_test.dart @@ -477,6 +477,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( diff --git a/packages/shorebird_cli/test/src/commands/release/android_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/android_releaser_test.dart index 6b73b2ad6..38faefe18 100644 --- a/packages/shorebird_cli/test/src/commands/release/android_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/android_releaser_test.dart @@ -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 = []; + 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 = []; + 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 = []; + await runWithOverrides( + () => androidReleaser.addObfuscationMapArgs(buildArgs), + ); + + expect(buildArgs, isEmpty); + }); + }); + group('buildReleaseArtifacts', () { late File aabFile; diff --git a/packages/shorebird_cli/test/src/commands/release/ios_framework_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/ios_framework_releaser_test.dart index 49ddadabd..6e8bc6503 100644 --- a/packages/shorebird_cli/test/src/commands/release/ios_framework_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/ios_framework_releaser_test.dart @@ -483,6 +483,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( diff --git a/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart index 4d0caf68a..af16792f9 100644 --- a/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/ios_releaser_test.dart @@ -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 = []; + await runWithOverrides( + () => iosReleaser.addObfuscationMapArgs(buildArgs), + ); + + expect( + buildArgs, + contains('--extra-gen-snapshot-options=--strip'), + ); + }); + }); + group('buildReleaseArtifacts', () { const flutterVersionAndRevision = '3.10.6 (83305b5088)'; const base64PublicKey = 'base64PublicKey'; @@ -627,6 +662,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( diff --git a/packages/shorebird_cli/test/src/commands/release/linux_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/linux_releaser_test.dart index fe25f2b83..ac2d4d7ae 100644 --- a/packages/shorebird_cli/test/src/commands/release/linux_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/linux_releaser_test.dart @@ -409,6 +409,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( diff --git a/packages/shorebird_cli/test/src/commands/release/macos_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/macos_releaser_test.dart index ff925f7ab..84f58bffe 100644 --- a/packages/shorebird_cli/test/src/commands/release/macos_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/macos_releaser_test.dart @@ -68,6 +68,10 @@ void main() { ); } + setUpAll(() { + registerFallbackValue(ReleasePlatform.macos); + }); + setUp(() { argResults = MockArgResults(); artifactBuilder = MockArtifactBuilder(); @@ -454,6 +458,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( diff --git a/packages/shorebird_cli/test/src/commands/release/windows_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release/windows_releaser_test.dart index 818efe824..c1fa2a477 100644 --- a/packages/shorebird_cli/test/src/commands/release/windows_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/windows_releaser_test.dart @@ -424,6 +424,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( diff --git a/packages/shorebird_cli/test/src/shorebird_flutter_test.dart b/packages/shorebird_cli/test/src/shorebird_flutter_test.dart index 68a15e91c..90d425a64 100644 --- a/packages/shorebird_cli/test/src/shorebird_flutter_test.dart +++ b/packages/shorebird_cli/test/src/shorebird_flutter_test.dart @@ -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( diff --git a/packages/shorebird_cli/test/src/validators/legacy_keep_debug_symbols_validator_test.dart b/packages/shorebird_cli/test/src/validators/legacy_keep_debug_symbols_validator_test.dart new file mode 100644 index 000000000..ad6c0bfcb --- /dev/null +++ b/packages/shorebird_cli/test/src/validators/legacy_keep_debug_symbols_validator_test.dart @@ -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 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)); + }, + ); + }); +}