feat(shorebird_cli): add IpaDiffer to get changes between to ipa files (#1004)

This commit is contained in:
Bryan Oltman
2023-08-03 15:39:27 -04:00
committed by GitHub
parent a94a4476b8
commit 5dfb19a922
13 changed files with 336 additions and 38 deletions
@@ -6,14 +6,6 @@ import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart';
abstract class AndroidArchiveDiffer extends ArchiveDiffer {
@override
bool containsPotentiallyBreakingAssetDiffs(FileSetDiff fileSetDiff) {
// If only files in this set have changed, we don't need to warn the user
// about asset differences.
const assetFileNamesToIgnore = {
'AssetManifest.bin',
'AssetManifest.json',
'NOTICES.Z',
};
final assetsDiff = assetsFileSetDiff(fileSetDiff);
// If assets were added, we need to warn the user about asset differences.
@@ -22,7 +14,10 @@ abstract class AndroidArchiveDiffer extends ArchiveDiffer {
}
return assetsDiff.changedPaths
.whereNot((path) => assetFileNamesToIgnore.contains(p.basename(path)))
.whereNot(
(path) =>
ArchiveDiffer.assetFileNamesToIgnore.contains(p.basename(path)),
)
.isNotEmpty;
}
@@ -30,25 +25,8 @@ abstract class AndroidArchiveDiffer extends ArchiveDiffer {
bool containsPotentiallyBreakingNativeDiffs(FileSetDiff fileSetDiff) =>
nativeFileSetDiff(fileSetDiff).isNotEmpty;
FileSetDiff assetsFileSetDiff(FileSetDiff fileSetDiff) => FileSetDiff(
addedPaths: fileSetDiff.addedPaths.where(_isAssetFilePath).toSet(),
removedPaths: fileSetDiff.removedPaths.where(_isAssetFilePath).toSet(),
changedPaths: fileSetDiff.changedPaths.where(_isAssetFilePath).toSet(),
);
FileSetDiff dartFileSetDiff(FileSetDiff fileSetDiff) => FileSetDiff(
addedPaths: fileSetDiff.addedPaths.where(_isDartFilePath).toSet(),
removedPaths: fileSetDiff.removedPaths.where(_isDartFilePath).toSet(),
changedPaths: fileSetDiff.changedPaths.where(_isDartFilePath).toSet(),
);
FileSetDiff nativeFileSetDiff(FileSetDiff fileSetDiff) => FileSetDiff(
addedPaths: fileSetDiff.addedPaths.where(_isNativeFilePath).toSet(),
removedPaths: fileSetDiff.removedPaths.where(_isNativeFilePath).toSet(),
changedPaths: fileSetDiff.changedPaths.where(_isNativeFilePath).toSet(),
);
static bool _isAssetFilePath(String filePath) {
@override
bool isAssetFilePath(String filePath) {
const assetDirNames = ['assets', 'res'];
const assetFileNames = ['AssetManifest.json'];
@@ -58,11 +36,12 @@ abstract class AndroidArchiveDiffer extends ArchiveDiffer {
assetFileNames.contains(p.basename(filePath));
}
static bool _isDartFilePath(String filePath) {
@override
bool isDartFilePath(String filePath) {
const dartFileNames = ['libapp.so', 'libflutter.so'];
return dartFileNames.contains(p.basename(filePath));
}
static bool _isNativeFilePath(String filePath) =>
p.extension(filePath) == '.dex';
@override
bool isNativeFilePath(String filePath) => p.extension(filePath) == '.dex';
}
@@ -2,3 +2,4 @@ export 'aab_differ.dart';
export 'aar_differ.dart';
export 'file_set_diff.dart';
export 'ipa.dart';
export 'ipa_differ.dart';
@@ -1,3 +1,5 @@
import 'package:collection/collection.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart';
/// Thrown when an [ArchiveDiffer] fails to generate a [FileSetDiff].
@@ -5,15 +7,69 @@ class DiffFailedException implements Exception {}
/// Computes content differences between two archives.
abstract class ArchiveDiffer {
/// Asset files that are not considered to be breaking changes.
static const assetFileNamesToIgnore = {
'AssetManifest.bin',
'AssetManifest.json',
'NOTICES.Z',
};
/// Files that have been added, removed, or that have changed between the
/// archives at the two provided paths.
FileSetDiff changedFiles(String oldArchivePath, String newArchivePath);
/// Whether there are asset differences between the archives that may cause
/// issues when patching a release.
bool containsPotentiallyBreakingAssetDiffs(FileSetDiff fileSetDiff);
bool containsPotentiallyBreakingAssetDiffs(FileSetDiff fileSetDiff) {
final assetsDiff = assetsFileSetDiff(fileSetDiff);
// If assets were added, we need to warn the user about asset differences.
if (assetsDiff.addedPaths.isNotEmpty) {
return true;
}
return assetsDiff.changedPaths
.whereNot(
(path) =>
ArchiveDiffer.assetFileNamesToIgnore.contains(p.basename(path)),
)
.isNotEmpty;
}
/// Whether there are native code differences between the archives that may
/// cause issues when patching a release.
bool containsPotentiallyBreakingNativeDiffs(FileSetDiff fileSetDiff);
/// Whether the provided file path represents a changed asset.
bool isAssetFilePath(String filePath);
/// Whether the provided file path represents changed Dart code.
bool isDartFilePath(String filePath);
/// Whether the provided file path represents changed Native code.
bool isNativeFilePath(String filePath);
/// The subset of [fileSetDiff] that contains only changes that result from
/// edited assets.
FileSetDiff assetsFileSetDiff(FileSetDiff fileSetDiff) => FileSetDiff(
addedPaths: fileSetDiff.addedPaths.where(isAssetFilePath).toSet(),
removedPaths: fileSetDiff.removedPaths.where(isAssetFilePath).toSet(),
changedPaths: fileSetDiff.changedPaths.where(isAssetFilePath).toSet(),
);
/// The subset of [fileSetDiff] that contains only changes that result from
/// edited Dart code.
FileSetDiff dartFileSetDiff(FileSetDiff fileSetDiff) => FileSetDiff(
addedPaths: fileSetDiff.addedPaths.where(isDartFilePath).toSet(),
removedPaths: fileSetDiff.removedPaths.where(isDartFilePath).toSet(),
changedPaths: fileSetDiff.changedPaths.where(isDartFilePath).toSet(),
);
/// The subset of [fileSetDiff] that contains only changes that result from
/// edited native code.
FileSetDiff nativeFileSetDiff(FileSetDiff fileSetDiff) => FileSetDiff(
addedPaths: fileSetDiff.addedPaths.where(isNativeFilePath).toSet(),
removedPaths: fileSetDiff.removedPaths.where(isNativeFilePath).toSet(),
changedPaths: fileSetDiff.changedPaths.where(isNativeFilePath).toSet(),
);
}
@@ -0,0 +1,74 @@
import 'dart:io';
import 'package:archive/archive_io.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart';
import 'package:shorebird_cli/src/archive_analysis/file_set_diff.dart';
/// Finds differences between two IPAs.
///
/// Asset changes will be in the `Assets.car` file (which is a combination of
/// the `.xcasset` catalogs in the Xcode project) and the `flutter_assets`
/// directory.
///
/// Native changes will appear in the Runner.app/Runner executable and non
/// Flutter.framework or App.framework files.
///
/// Dart changes will appear in the App.framework/App executable.
class IpaDiffer extends ArchiveDiffer {
static const binaryFiles = {
'App.framework/App',
'Flutter.framework/Flutter',
};
static RegExp appRegex = RegExp(r'^Payload/[\w\-. ]+.app/[\w\-. ]+$');
@override
FileSetDiff changedFiles(String oldArchivePath, String newArchivePath) =>
FileSetDiff.fromPathHashes(
oldPathHashes: _fileHashes(File(oldArchivePath)),
newPathHashes: _fileHashes(File(newArchivePath)),
);
@override
bool containsPotentiallyBreakingAssetDiffs(FileSetDiff fileSetDiff) =>
assetsFileSetDiff(fileSetDiff).isNotEmpty;
@override
bool containsPotentiallyBreakingNativeDiffs(FileSetDiff fileSetDiff) {
// Because the mach-o binaries are signed (we believe with in expiration
// date), they will always have different hashes, even if the code used to
// generate them is identical.
//
// TODO(bryanoltman): support mach-o binary diffing.
// We can do this using the `codesign --remove-signature` command, but this
// is slow and requires a temporary directory to store the unsigned binary.
return false;
}
@override
bool isAssetFilePath(String filePath) {
/// The flutter_assets directory contains the assets listed in the assets
/// section of the pubspec.yaml file.
/// Assets.car is the compiled asset catalog(s) (.xcassets files).
return p.basename(filePath) == 'Assets.car' ||
p.split(filePath).contains('flutter_assets');
}
@override
bool isDartFilePath(String filePath) =>
filePath.endsWith('App.framework/App');
@override
bool isNativeFilePath(String filePath) => appRegex.hasMatch(filePath);
PathHashes _fileHashes(File ipa) {
final zipDirectory = ZipDirectory.read(InputFileStream(ipa.path));
return {
for (final file in zipDirectory.fileHeaders)
// Zip files contain an (optional) crc32 checksum for a file. IPAs seem
// to always include this for files, so a quick way for us to tell if
// file contents differ is if their checksums differ.
file.filename: file.crc32!.toString()
};
}
}
+8 -4
View File
@@ -2,7 +2,11 @@ The ipa files in this folder were generated by building the stock Flutter counte
Some of their contents has been removed to reduce the size of the files. All .dylib files have been removed.
Files:
- base.ipa is meant to represent an ipa uploaded as part of a release.
- no_version.ipa is base.ipa with the version info removed from the Info.plist, along with several other files to save space.
- no_plist.ipa is base.ipa with the Info.plist (and many other parts) removed.
Files:
- base.ipa is meant to represent an ipa uploaded as part of a release. App.framework/App and Flutter.framework/Flutter have been removed to save space.
- no_version.ipa an .ipa with the version info removed from the Info.plist, along with several other files to save space.
- no_plist.ipa an .ipa with the Info.plist (and many other parts) removed.
- app_file_space.ipa is an .ipa with a space in the .app file name.
- asset_changes.ipa is built from the same codebase as base.ipa with a change made to one of the assets. App.framework/App and Flutter.framework/Flutter have been removed to save space.
- dart_changes.ipa is built from the same codebase as base.ipa with a change made to the dart code. App.framework/App and Flutter.framework/Flutter have been removed to save space.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -22,7 +22,7 @@ void main() {
differ = AabDiffer();
});
group('changedPaths', () {
group('changedFiles', () {
test('finds no differences between the same aab', () {
expect(differ.changedFiles(baseAabPath, baseAabPath), isEmpty);
});
@@ -16,7 +16,7 @@ void main() {
differ = AarDiffer();
});
group('changedPaths', () {
group('changedFiles', () {
test('finds no differences between the same aar', () {
expect(differ.changedFiles(baseAarPath, baseAarPath), isEmpty);
});
@@ -0,0 +1,79 @@
import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart';
import 'package:shorebird_cli/src/archive_analysis/file_set_diff.dart';
import 'package:test/test.dart';
const assetFilePath = 'some/assets/file';
void main() {
group(ArchiveDiffer, () {
late TestArchiveDiffer archiveDiffer;
setUp(() {
archiveDiffer = TestArchiveDiffer();
});
group('containsPotentiallyBreakingAssetDiffs', () {
test('returns true if any assets were added', () {
archiveDiffer.changedFileSetDiff = FileSetDiff(
addedPaths: {assetFilePath},
removedPaths: {},
changedPaths: {},
);
expect(
archiveDiffer.containsPotentiallyBreakingAssetDiffs(
archiveDiffer.changedFileSetDiff,
),
isTrue,
);
});
test('returns false if changed assets are all in the ignore list', () {
archiveDiffer.changedFileSetDiff = FileSetDiff(
addedPaths: {},
removedPaths: {},
changedPaths: {'AssetManifest.bin'},
);
expect(
archiveDiffer.containsPotentiallyBreakingAssetDiffs(
archiveDiffer.changedFileSetDiff,
),
isFalse,
);
});
test('returns true if changed assets are not all in the ignore list', () {
archiveDiffer.changedFileSetDiff = FileSetDiff(
addedPaths: {},
removedPaths: {},
changedPaths: {assetFilePath},
);
expect(
archiveDiffer.containsPotentiallyBreakingAssetDiffs(
archiveDiffer.changedFileSetDiff,
),
isTrue,
);
});
});
});
}
class TestArchiveDiffer extends ArchiveDiffer {
FileSetDiff changedFileSetDiff = FileSetDiff.empty();
@override
FileSetDiff changedFiles(String oldArchivePath, String newArchivePath) =>
changedFileSetDiff;
@override
bool containsPotentiallyBreakingNativeDiffs(FileSetDiff fileSetDiff) => false;
@override
bool isAssetFilePath(String filePath) => filePath == assetFilePath;
@override
bool isDartFilePath(String filePath) => true;
@override
bool isNativeFilePath(String filePath) => true;
}
@@ -0,0 +1,105 @@
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart';
import 'package:test/test.dart';
void main() {
final ipaFixturesBasePath = p.join('test', 'fixtures', 'ipas');
final baseIpaPath = p.join(ipaFixturesBasePath, 'base.ipa');
final changedAssetIpaPath = p.join(ipaFixturesBasePath, 'asset_changes.ipa');
final changedDartIpaPath = p.join(ipaFixturesBasePath, 'dart_changes.ipa');
final changedSwiftIpaPath = p.join(ipaFixturesBasePath, 'swift_changes.ipa');
late IpaDiffer differ;
setUp(() {
differ = IpaDiffer();
});
group(IpaDiffer, () {
group('changedPaths', () {
test('finds no differences between the same ipa', () {
expect(differ.changedFiles(baseIpaPath, baseIpaPath), isEmpty);
});
test('finds differences between two different ipas', () {
expect(
differ.changedFiles(baseIpaPath, changedAssetIpaPath).changedPaths,
{
'Payload/Runner.app/_CodeSignature/CodeResources',
'Payload/Runner.app/Runner',
'Payload/Runner.app/Frameworks/Flutter.framework/Flutter',
'Payload/Runner.app/Frameworks/App.framework/_CodeSignature/CodeResources',
'Payload/Runner.app/Frameworks/App.framework/App',
'Payload/Runner.app/Frameworks/App.framework/flutter_assets/assets/asset.json',
'Symbols/4C4C4411-5555-3144-A13A-E47369D8ACD5.symbols',
'Symbols/BC970605-0A53-3457-8736-D7A870AB6E71.symbols',
'Symbols/0CBBC9EF-0745-3074-81B7-765F5B4515FD.symbols',
},
);
});
});
group('changedFiles', () {
test('detects asset changes', () {
final fileSetDiff =
differ.changedFiles(baseIpaPath, changedAssetIpaPath);
expect(differ.assetsFileSetDiff(fileSetDiff), isNotEmpty);
expect(differ.dartFileSetDiff(fileSetDiff), isNotEmpty);
expect(differ.nativeFileSetDiff(fileSetDiff), isNotEmpty);
});
test('detects dart changes', () {
final fileSetDiff =
differ.changedFiles(baseIpaPath, changedDartIpaPath);
expect(differ.assetsFileSetDiff(fileSetDiff), isEmpty);
expect(differ.dartFileSetDiff(fileSetDiff), isNotEmpty);
expect(differ.nativeFileSetDiff(fileSetDiff), isNotEmpty);
});
test('detects swift changes', () {
final fileSetDiff =
differ.changedFiles(baseIpaPath, changedSwiftIpaPath);
expect(differ.assetsFileSetDiff(fileSetDiff), isEmpty);
expect(differ.dartFileSetDiff(fileSetDiff), isNotEmpty);
expect(differ.nativeFileSetDiff(fileSetDiff), isNotEmpty);
});
});
group('containsPotentiallyBreakingAssetDiffs', () {
test('returns true if a file in flutter_assets has changed', () {
final fileSetDiff = differ.changedFiles(
baseIpaPath,
changedAssetIpaPath,
);
expect(
differ.containsPotentiallyBreakingAssetDiffs(fileSetDiff),
isTrue,
);
});
test('returns false if no files in flutter_assets has changed', () {
final fileSetDiff = differ.changedFiles(
baseIpaPath,
changedDartIpaPath,
);
expect(
differ.containsPotentiallyBreakingAssetDiffs(fileSetDiff),
isFalse,
);
});
});
group('containsPotentiallyBreakingNativeDiffs', () {
test("always returns false, as we don't check for this yet", () {
final fileSetDiff = differ.changedFiles(
baseIpaPath,
changedSwiftIpaPath,
);
expect(
differ.containsPotentiallyBreakingNativeDiffs(fileSetDiff),
isFalse,
);
});
});
});
}