feat: add semantic DEX diffing to suppress false native change warnings (#3644)

This commit is contained in:
Eric Seidel
2026-03-20 23:13:44 -07:00
committed by GitHub
parent 989bca0a1c
commit fbbb535080
25 changed files with 3147 additions and 5 deletions
+2
View File
@@ -22,6 +22,7 @@ words:
- bitcode
- bryanoltman
- bundletool
- Dalvik # Android Dalvik VM / DEX file format
- canvaskit
- carryforward
- cipd
@@ -90,6 +91,7 @@ words:
- PRNG
- propertylistserialization
- propertylistserialization
- protos # Protocol buffer generated files
- pubspec
- pwsh
- quantiles
+57
View File
@@ -0,0 +1,57 @@
# dex
DEX binary parser and semantic differ for Android Dalvik Executable files.
## Usage
### Parsing
```dart
import 'package:dex/dex.dart';
final bytes = File('classes.dex').readAsBytesSync();
final dex = const DexParser().parse(bytes);
print('${dex.strings.length} strings');
print('${dex.classDefs.length} classes');
for (final classDef in dex.classDefs) {
print('${classDef.className} extends ${classDef.superclass}');
}
```
### Diffing
```dart
import 'package:dex/dex.dart';
const parser = DexParser();
final oldDex = parser.parse(oldBytes);
final newDex = parser.parse(newBytes);
final result = const DexDiffer().diff(oldDex, newDex);
if (result.isSafe) {
print('Only safe differences (e.g. source file paths)');
} else {
print(result.describe());
}
```
## What gets compared
The differ compares DEX files structurally with full index remapping to
handle string table reordering caused by build path differences.
| Layer | What's checked |
|-------|---------------|
| Classes | Added/removed classes, access flags, superclass, interfaces |
| Methods | Added/removed methods, access flags |
| Fields | Added/removed fields, access flags |
| Bytecode | Instruction-by-instruction comparison with pool-index remapping |
| Try/catch | Handler types and addresses |
| Annotations | Class, field, method, and parameter annotations |
| Static values | Static field initial values |
Only `source_file` attributes and `debug_info` offsets are classified as
safe to differ. Everything else is treated as breaking.
+1
View File
@@ -0,0 +1 @@
include: ../../analysis_options.yaml
+30
View File
@@ -0,0 +1,30 @@
export 'src/dex_differ.dart'
show DexDiffResult, DexDiffer, DexDifference, DexDifferenceKind;
export 'src/dex_parser.dart'
show
DexAnnotation,
DexAnnotationDirectory,
DexClassData,
DexClassDef,
DexCodeItem,
DexEncodedAnnotation,
DexEncodedArray,
DexEncodedBoolean,
DexEncodedEnum,
DexEncodedField,
DexEncodedFieldRef,
DexEncodedMethod,
DexEncodedMethodHandle,
DexEncodedMethodRef,
DexEncodedMethodType,
DexEncodedNull,
DexEncodedPrimitive,
DexEncodedString,
DexEncodedType,
DexEncodedValue,
DexFieldId,
DexFile,
DexHeader,
DexMethodId,
DexParser,
DexProtoId;
+459
View File
@@ -0,0 +1,459 @@
import 'package:dex/src/dex_parser.dart';
/// The kind of difference found between two DEX files.
enum DexDifferenceKind {
/// Source file attribute changed (safe — build path difference).
sourceFileChanged,
/// A class was added.
classAdded,
/// A class was removed.
classRemoved,
/// A method was added to a class.
methodAdded,
/// A method was removed from a class.
methodRemoved,
/// A field was added to a class.
fieldAdded,
/// A field was removed from a class.
fieldRemoved,
/// Access flags changed on a class, method, or field.
accessFlagsChanged,
/// The superclass of a class changed.
superclassChanged,
/// The interface list of a class changed.
interfacesChanged,
/// Method bytecode changed.
bytecodeChanged,
/// Annotations changed.
annotationsChanged,
/// Static field initial values changed.
staticValuesChanged;
/// Whether this kind of difference is safe (does not affect runtime
/// behavior).
bool get isSafe => this == sourceFileChanged;
}
/// {@template dex_difference}
/// A single difference found between two DEX files.
/// {@endtemplate}
class DexDifference {
/// {@macro dex_difference}
const DexDifference({required this.kind, required this.description});
/// The classification of this difference.
final DexDifferenceKind kind;
/// A human-readable description of the difference.
final String description;
}
/// {@template dex_diff_result}
/// The result of comparing two DEX files.
/// {@endtemplate}
class DexDiffResult {
/// {@macro dex_diff_result}
const DexDiffResult({required this.differences});
/// Creates an empty (identical) diff result.
const DexDiffResult.identical() : differences = const [];
/// All differences found between the two DEX files.
final List<DexDifference> differences;
/// Differences that are safe to ignore (e.g. source file paths).
Iterable<DexDifference> get safeDifferences =>
differences.where((d) => d.kind.isSafe);
/// Differences that indicate real code changes.
Iterable<DexDifference> get breakingDifferences =>
differences.where((d) => !d.kind.isSafe);
/// Whether all differences are safe to ignore.
bool get isSafe => breakingDifferences.isEmpty;
/// A human-readable summary of the differences.
String describe() {
final buffer = StringBuffer();
final safe = safeDifferences.toList();
final breaking = breakingDifferences.toList();
if (safe.isNotEmpty) {
buffer.writeln(
'Safe differences (${safe.length}):',
);
for (final diff in safe) {
buffer.writeln(' - ${diff.description}');
}
}
if (breaking.isNotEmpty) {
buffer.writeln(
'Breaking differences (${breaking.length}):',
);
for (final diff in breaking) {
buffer.writeln(' - ${diff.description}');
}
}
return buffer.toString().trimRight();
}
}
/// {@template dex_differ}
/// Compares two parsed [DexFile]s and produces a [DexDiffResult]
/// describing the semantic differences between them.
/// {@endtemplate}
class DexDiffer {
/// {@macro dex_differ}
const DexDiffer();
/// Compares two DEX files and returns the differences.
DexDiffResult diff(DexFile oldFile, DexFile newFile) {
final differences = <DexDifference>[];
final oldClasses = {
for (final c in oldFile.classDefs) c.className: c,
};
final newClasses = {
for (final c in newFile.classDefs) c.className: c,
};
final oldClassNames = oldClasses.keys.toSet();
final newClassNames = newClasses.keys.toSet();
for (final added in newClassNames.difference(oldClassNames)) {
differences.add(
DexDifference(
kind: DexDifferenceKind.classAdded,
description: 'Class added: $added',
),
);
}
for (final removed in oldClassNames.difference(newClassNames)) {
differences.add(
DexDifference(
kind: DexDifferenceKind.classRemoved,
description: 'Class removed: $removed',
),
);
}
final matched = oldClassNames.intersection(newClassNames);
for (final name in matched) {
_compareClassStructure(
oldClasses[name]!,
newClasses[name]!,
differences,
);
}
// If there are already breaking structural differences, no
// need to do deeper comparison — we'll report breaking
// regardless.
if (differences.any((d) => !d.kind.isSafe)) {
return DexDiffResult(differences: differences);
}
// Compare bytecode, annotations, and static values using
// the pre-resolved canonical representations.
for (final name in matched) {
_compareClassData(
name,
oldClasses[name]!,
newClasses[name]!,
differences,
);
}
return DexDiffResult(differences: differences);
}
void _compareClassStructure(
DexClassDef oldClass,
DexClassDef newClass,
List<DexDifference> differences,
) {
final name = oldClass.className;
if (oldClass.sourceFile != newClass.sourceFile) {
differences.add(
DexDifference(
kind: DexDifferenceKind.sourceFileChanged,
description:
'$name: source file changed from '
'"${oldClass.sourceFile}" to '
'"${newClass.sourceFile}"',
),
);
}
if (oldClass.accessFlags != newClass.accessFlags) {
differences.add(
DexDifference(
kind: DexDifferenceKind.accessFlagsChanged,
description:
'$name: class access flags changed from '
'0x${oldClass.accessFlags.toRadixString(16)} to '
'0x${newClass.accessFlags.toRadixString(16)}',
),
);
}
if (oldClass.superclass != newClass.superclass) {
differences.add(
DexDifference(
kind: DexDifferenceKind.superclassChanged,
description:
'$name: superclass changed from '
'${oldClass.superclass} to '
'${newClass.superclass}',
),
);
}
if (!_listEquals(oldClass.interfaces, newClass.interfaces)) {
differences.add(
DexDifference(
kind: DexDifferenceKind.interfacesChanged,
description: '$name: interfaces changed',
),
);
}
_compareMembers(
className: name,
oldData: oldClass.classData,
newData: newClass.classData,
extract: (data) => {
for (final f in [
...data.staticFields,
...data.instanceFields,
])
'${f.field.className}.${f.field.fieldName}'
':${f.field.typeName}':
f.accessFlags,
},
memberLabel: 'field',
addedKind: DexDifferenceKind.fieldAdded,
removedKind: DexDifferenceKind.fieldRemoved,
differences: differences,
);
_compareMembers(
className: name,
oldData: oldClass.classData,
newData: newClass.classData,
extract: (data) => {
for (final m in [
...data.directMethods,
...data.virtualMethods,
])
_methodKey(m): m.accessFlags,
},
memberLabel: 'method',
addedKind: DexDifferenceKind.methodAdded,
removedKind: DexDifferenceKind.methodRemoved,
differences: differences,
);
}
// -- Per-class data comparison ------------------------------------------
void _compareClassData(
String className,
DexClassDef oldClass,
DexClassDef newClass,
List<DexDifference> differences,
) {
_compareCodeItems(
className,
oldClass,
newClass,
differences,
);
if (oldClass.annotations != newClass.annotations) {
// Both null means equal — this only triggers when they
// actually differ (including one being null and other not).
if (oldClass.annotations != null || newClass.annotations != null) {
differences.add(
DexDifference(
kind: DexDifferenceKind.annotationsChanged,
description:
oldClass.annotations == null || newClass.annotations == null
? '$className: annotations added or removed'
: '$className: annotations changed',
),
);
}
}
if (!_nullableListEquals(
oldClass.staticValues,
newClass.staticValues,
)) {
if (oldClass.staticValues != null || newClass.staticValues != null) {
differences.add(
DexDifference(
kind: DexDifferenceKind.staticValuesChanged,
description:
oldClass.staticValues == null || newClass.staticValues == null
? '$className: static field initial values '
'added or removed'
: '$className: static field initial values '
'changed',
),
);
}
}
}
void _compareCodeItems(
String className,
DexClassDef oldClass,
DexClassDef newClass,
List<DexDifference> differences,
) {
final oldMethods = _methodMap(oldClass.classData);
final newMethods = _methodMap(newClass.classData);
for (final entry in oldMethods.entries) {
final newMethod = newMethods[entry.key];
if (newMethod == null) continue; // caught structurally
final oldCode = entry.value.code;
final newCode = newMethod.code;
if (!_codeItemsEqual(oldCode, newCode)) {
differences.add(
DexDifference(
kind: DexDifferenceKind.bytecodeChanged,
description:
'$className: bytecode changed in '
'${entry.key}',
),
);
}
}
}
static Map<String, DexEncodedMethod> _methodMap(
DexClassData? data,
) {
if (data == null) return const {};
return {
for (final m in [
...data.directMethods,
...data.virtualMethods,
])
_methodKey(m): m,
};
}
static bool _codeItemsEqual(
DexCodeItem? oldCode,
DexCodeItem? newCode,
) {
if (oldCode == null && newCode == null) return true;
if (oldCode == null || newCode == null) return false;
return oldCode.registersSize == newCode.registersSize &&
oldCode.insSize == newCode.insSize &&
oldCode.outsSize == newCode.outsSize &&
oldCode.canonicalBytecode == newCode.canonicalBytecode;
}
// -- Member comparison (shared helper) ----------------------------------
void _compareMembers({
required String className,
required DexClassData? oldData,
required DexClassData? newData,
required Map<String, int> Function(DexClassData) extract,
required String memberLabel,
required DexDifferenceKind addedKind,
required DexDifferenceKind removedKind,
required List<DexDifference> differences,
}) {
final oldMembers = oldData != null ? extract(oldData) : <String, int>{};
final newMembers = newData != null ? extract(newData) : <String, int>{};
final oldKeys = oldMembers.keys.toSet();
final newKeys = newMembers.keys.toSet();
for (final added in newKeys.difference(oldKeys)) {
differences.add(
DexDifference(
kind: addedKind,
description: '$className: $memberLabel added: $added',
),
);
}
for (final removed in oldKeys.difference(newKeys)) {
differences.add(
DexDifference(
kind: removedKind,
description: '$className: $memberLabel removed: $removed',
),
);
}
for (final common in oldKeys.intersection(newKeys)) {
if (oldMembers[common] != newMembers[common]) {
differences.add(
DexDifference(
kind: DexDifferenceKind.accessFlagsChanged,
description:
'$className: $memberLabel $common access '
'flags changed from '
'0x${oldMembers[common]!.toRadixString(16)} '
'to '
'0x${newMembers[common]!.toRadixString(16)}',
),
);
}
}
}
// -- Utility methods ----------------------------------------------------
static String _methodKey(DexEncodedMethod m) {
final params = m.method.proto.parameterTypes.join(', ');
return '${m.method.className}.${m.method.methodName}'
'($params)${m.method.proto.returnType}';
}
bool _listEquals<T>(List<T> a, List<T> b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
static bool _nullableListEquals<T>(List<T>? a, List<T>? b) {
if (a == null && b == null) return true;
if (a == null || b == null) return false;
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
}
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
name: dex
description: DEX binary parser and semantic differ for Android Dalvik Executable files.
version: 0.1.0
publish_to: none
resolution: workspace
environment:
sdk: ">=3.9.0 <4.0.0"
dependencies:
meta: ^1.16.0
dev_dependencies:
path: ^1.9.1
test: ^1.26.3
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+255
View File
@@ -0,0 +1,255 @@
// cspell:words Lcom Ljava
import 'dart:io';
import 'package:dex/src/dex_differ.dart';
import 'package:dex/src/dex_parser.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
void main() {
group(DexDiffer, () {
const parser = DexParser();
const differ = DexDiffer();
final dexFixturesPath = p.join('test', 'fixtures', 'dex');
late DexFile baseDex;
setUp(() {
baseDex = parser.parse(
File(p.join(dexFixturesPath, 'base.dex')).readAsBytesSync(),
);
});
DexFile parseDexFixture(String name) {
return parser.parse(
File(p.join(dexFixturesPath, name)).readAsBytesSync(),
);
}
test('identical files produce empty diff', () {
final result = differ.diff(baseDex, baseDex);
expect(result.safeDifferences, isEmpty);
expect(result.breakingDifferences, isEmpty);
expect(result.isSafe, isTrue);
});
test('path-only difference is classified as safe', () {
final pathDex = parseDexFixture('path_only_diff.dex');
final result = differ.diff(baseDex, pathDex);
expect(result.safeDifferences, isNotEmpty);
expect(result.breakingDifferences, isEmpty);
expect(result.isSafe, isTrue);
expect(
result.safeDifferences.every(
(d) => d.kind == DexDifferenceKind.sourceFileChanged,
),
isTrue,
);
});
test('added method is classified as breaking', () {
final methodAddedDex = parseDexFixture('method_added.dex');
final result = differ.diff(baseDex, methodAddedDex);
expect(result.isSafe, isFalse);
expect(
result.breakingDifferences.any(
(d) => d.kind == DexDifferenceKind.methodAdded,
),
isTrue,
);
});
test('removed field is classified as breaking', () {
final fieldRemovedDex = parseDexFixture('field_removed.dex');
final result = differ.diff(baseDex, fieldRemovedDex);
expect(result.isSafe, isFalse);
expect(
result.breakingDifferences.any(
(d) => d.kind == DexDifferenceKind.fieldRemoved,
),
isTrue,
);
});
test('changed superclass is classified as breaking', () {
final superclassChangedDex = parseDexFixture('superclass_changed.dex');
final result = differ.diff(baseDex, superclassChangedDex);
expect(result.isSafe, isFalse);
expect(
result.breakingDifferences.any(
(d) => d.kind == DexDifferenceKind.superclassChanged,
),
isTrue,
);
});
test('mixed safe and breaking differences are not safe', () {
final superclassChangedDex = parseDexFixture('superclass_changed.dex');
final result = differ.diff(baseDex, superclassChangedDex);
expect(result.isSafe, isFalse);
});
group('bytecode comparison', () {
late DexFile baseWithCode;
setUp(() {
baseWithCode = parseDexFixture('base_with_code.dex');
});
test('identical bytecode is safe', () {
final result = differ.diff(baseWithCode, baseWithCode);
expect(result.isSafe, isTrue);
});
test('changed bytecode is breaking', () {
final codeChanged = parseDexFixture('code_changed.dex');
final result = differ.diff(baseWithCode, codeChanged);
expect(result.isSafe, isFalse);
expect(
result.breakingDifferences.any(
(d) => d.kind == DexDifferenceKind.bytecodeChanged,
),
isTrue,
);
});
test('path-only diff with identical bytecode is safe', () {
final pathWithCode = parseDexFixture('path_only_with_code.dex');
final result = differ.diff(baseWithCode, pathWithCode);
expect(result.isSafe, isTrue);
expect(result.safeDifferences, isNotEmpty);
});
});
group('describe', () {
test('formats safe-only differences', () {
final pathDex = parseDexFixture('path_only_diff.dex');
final result = differ.diff(baseDex, pathDex);
final description = result.describe();
expect(description, contains('Safe differences'));
expect(description, contains('source file changed'));
expect(description, isNot(contains('Breaking differences')));
});
test('formats breaking differences', () {
final methodAddedDex = parseDexFixture('method_added.dex');
final result = differ.diff(baseDex, methodAddedDex);
final description = result.describe();
expect(description, contains('Breaking differences'));
expect(description, contains('method added'));
});
test('empty diff produces empty string', () {
final result = differ.diff(baseDex, baseDex);
expect(result.describe(), isEmpty);
});
test('path-only diff produces exact output', () {
final pathDex = parseDexFixture('path_only_diff.dex');
final result = differ.diff(baseDex, pathDex);
expect(
result.describe(),
equals(
'''
Safe differences (2):
- Lcom/example/Helper;: source file changed from "Helper.java" to "/different/path/Helper.java"
- Lcom/example/MyClass;: source file changed from "MyClass.java" to "/different/path/MyClass.java"''',
),
);
});
test('method-added diff produces exact output', () {
final methodAddedDex = parseDexFixture('method_added.dex');
final result = differ.diff(baseDex, methodAddedDex);
expect(
result.describe(),
equals(
'''
Breaking differences (1):
- Lcom/example/MyClass;: method added: '''
'Lcom/example/MyClass;.newMethod()V',
),
);
});
test('bytecode-changed diff produces exact output', () {
final baseWithCode = parseDexFixture('base_with_code.dex');
final codeChanged = parseDexFixture('code_changed.dex');
final result = differ.diff(baseWithCode, codeChanged);
expect(
result.describe(),
equals(
'''
Breaking differences (1):
- Lcom/example/Foo;: bytecode changed in '''
'Lcom/example/Foo;.<init>()V',
),
);
});
});
group('DexDiffResult.identical', () {
test('creates an empty result', () {
const result = DexDiffResult.identical();
expect(result.safeDifferences, isEmpty);
expect(result.breakingDifferences, isEmpty);
expect(result.isSafe, isTrue);
});
});
group('DexDifferenceKind.isSafe', () {
test('sourceFileChanged is safe', () {
expect(DexDifferenceKind.sourceFileChanged.isSafe, isTrue);
});
test('classAdded is not safe', () {
expect(DexDifferenceKind.classAdded.isSafe, isFalse);
});
test('classRemoved is not safe', () {
expect(DexDifferenceKind.classRemoved.isSafe, isFalse);
});
test('methodAdded is not safe', () {
expect(DexDifferenceKind.methodAdded.isSafe, isFalse);
});
test('methodRemoved is not safe', () {
expect(DexDifferenceKind.methodRemoved.isSafe, isFalse);
});
test('fieldAdded is not safe', () {
expect(DexDifferenceKind.fieldAdded.isSafe, isFalse);
});
test('fieldRemoved is not safe', () {
expect(DexDifferenceKind.fieldRemoved.isSafe, isFalse);
});
test('accessFlagsChanged is not safe', () {
expect(DexDifferenceKind.accessFlagsChanged.isSafe, isFalse);
});
test('superclassChanged is not safe', () {
expect(DexDifferenceKind.superclassChanged.isSafe, isFalse);
});
test('interfacesChanged is not safe', () {
expect(DexDifferenceKind.interfacesChanged.isSafe, isFalse);
});
test('bytecodeChanged is not safe', () {
expect(DexDifferenceKind.bytecodeChanged.isSafe, isFalse);
});
test('annotationsChanged is not safe', () {
expect(DexDifferenceKind.annotationsChanged.isSafe, isFalse);
});
test('staticValuesChanged is not safe', () {
expect(DexDifferenceKind.staticValuesChanged.isSafe, isFalse);
});
});
});
}
+164
View File
@@ -0,0 +1,164 @@
// cspell:words Lcom Ljava Uleb
import 'dart:io';
import 'dart:typed_data';
import 'package:dex/src/dex_parser.dart';
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
void main() {
group(DexParser, () {
const parser = DexParser();
late Uint8List baseDexBytes;
setUp(() {
baseDexBytes = File(
p.join('test', 'fixtures', 'dex', 'base.dex'),
).readAsBytesSync();
});
group('parse', () {
test('parses a valid DEX file', () {
final dex = parser.parse(baseDexBytes);
expect(dex.strings, hasLength(13));
expect(dex.typeDescriptors, hasLength(5));
expect(dex.protoIds, hasLength(2));
expect(dex.fieldIds, hasLength(2));
expect(dex.methodIds, hasLength(3));
expect(dex.classDefs, hasLength(2));
});
test('resolves string table values', () {
final dex = parser.parse(baseDexBytes);
expect(dex.strings, contains('<init>'));
expect(dex.strings, contains('Lcom/example/MyClass;'));
expect(dex.strings, contains('Ljava/lang/Object;'));
expect(dex.strings, contains('myField'));
expect(dex.strings, contains('getValue'));
});
test('resolves type descriptors', () {
final dex = parser.parse(baseDexBytes);
expect(dex.typeDescriptors, contains('I'));
expect(dex.typeDescriptors, contains('V'));
expect(dex.typeDescriptors, contains('Lcom/example/MyClass;'));
expect(dex.typeDescriptors, contains('Lcom/example/Helper;'));
expect(dex.typeDescriptors, contains('Ljava/lang/Object;'));
});
test('resolves field descriptors', () {
final dex = parser.parse(baseDexBytes);
final valueField = dex.fieldIds.firstWhere(
(f) => f.fieldName == 'value',
);
expect(valueField.className, equals('Lcom/example/Helper;'));
expect(valueField.typeName, equals('I'));
final myField = dex.fieldIds.firstWhere(
(f) => f.fieldName == 'myField',
);
expect(myField.className, equals('Lcom/example/MyClass;'));
expect(myField.typeName, equals('I'));
});
test('resolves method descriptors', () {
final dex = parser.parse(baseDexBytes);
final getValue = dex.methodIds.firstWhere(
(m) => m.methodName == 'getValue',
);
expect(getValue.className, equals('Lcom/example/Helper;'));
expect(getValue.proto.returnType, equals('I'));
expect(getValue.proto.parameterTypes, isEmpty);
});
test('resolves class definitions', () {
final dex = parser.parse(baseDexBytes);
final myClass = dex.classDefs.firstWhere(
(c) => c.className == 'Lcom/example/MyClass;',
);
expect(myClass.accessFlags, equals(1)); // public
expect(myClass.superclass, equals('Ljava/lang/Object;'));
expect(myClass.interfaces, isEmpty);
expect(myClass.sourceFile, equals('MyClass.java'));
expect(myClass.classData, isNotNull);
expect(myClass.classData!.instanceFields, hasLength(1));
expect(myClass.classData!.directMethods, hasLength(1));
});
test('resolves class data fields and methods', () {
final dex = parser.parse(baseDexBytes);
final helper = dex.classDefs.firstWhere(
(c) => c.className == 'Lcom/example/Helper;',
);
expect(helper.classData, isNotNull);
expect(helper.classData!.instanceFields, hasLength(1));
expect(
helper.classData!.instanceFields[0].field.fieldName,
equals('value'),
);
expect(helper.classData!.directMethods, hasLength(1));
expect(
helper.classData!.directMethods[0].method.methodName,
equals('<init>'),
);
expect(helper.classData!.virtualMethods, hasLength(1));
expect(
helper.classData!.virtualMethods[0].method.methodName,
equals('getValue'),
);
});
test('parses code items', () {
final dex = parser.parse(
File(
p.join('test', 'fixtures', 'dex', 'base_with_code.dex'),
).readAsBytesSync(),
);
final method = dex.classDefs[0].classData!.directMethods[0];
expect(method.code, isA<DexCodeItem>());
expect(method.code!.registersSize, isNonZero);
});
test('DexCodeItem has expected field values', () {
final dex = parser.parse(
File(
p.join('test', 'fixtures', 'dex', 'base_with_code.dex'),
).readAsBytesSync(),
);
final method = dex.classDefs[0].classData!.directMethods[0];
final code = method.code!;
expect(code.registersSize, equals(1));
expect(code.insSize, equals(1));
expect(code.outsSize, equals(0));
expect(code.canonicalBytecode, isNotEmpty);
});
test('parses annotations and staticValues', () {
final dex = parser.parse(baseDexBytes);
// Our test fixtures don't have annotations or static values.
for (final classDef in dex.classDefs) {
expect(classDef.annotations, isNull);
expect(classDef.staticValues, isNull);
}
});
});
group('error handling', () {
test('throws FormatException for truncated file', () {
expect(
() => parser.parse(Uint8List.fromList([0x64, 0x65, 0x78])),
throwsFormatException,
);
});
test('throws FormatException for invalid magic bytes', () {
final bad = Uint8List(112)..fillRange(0, 112, 0);
expect(() => parser.parse(bad), throwsFormatException);
});
});
// readUleb128, readUint16, and readUint32 are now internal to
// _BinaryReader and exercised transitively through parse().
});
}
@@ -1,5 +1,25 @@
// cspell:words unparseable
import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:dex/dex.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';
/// A [FileSetDiff] that also carries semantic DEX diff results.
class AndroidFileSetDiff extends FileSetDiff {
/// Creates an [AndroidFileSetDiff] with DEX diff results.
const AndroidFileSetDiff({
required super.addedPaths,
required super.removedPaths,
required super.changedPaths,
this.dexDiffResults = const {},
});
/// DEX diff results for breaking changes, keyed by file path.
final Map<String, DexDiffResult> dexDiffResults;
}
/// {@template android_archive_differ}
/// Finds differences between two Android archives (either AABs or AARs).
@@ -29,6 +49,85 @@ class AndroidArchiveDiffer extends ArchiveDiffer {
/// {@macro android_archive_differ}
const AndroidArchiveDiffer();
@override
Future<AndroidFileSetDiff> changedFiles(
String oldArchivePath,
String newArchivePath,
) async {
final fileSetDiff = await super.changedFiles(
oldArchivePath,
newArchivePath,
);
final dexPaths = fileSetDiff.changedPaths
.where((p) => p.endsWith('.dex'))
.toList();
if (dexPaths.isEmpty) {
return AndroidFileSetDiff(
addedPaths: fileSetDiff.addedPaths,
removedPaths: fileSetDiff.removedPaths,
changedPaths: fileSetDiff.changedPaths,
);
}
// Extract DEX file bytes from both archives.
final oldDexBytes = _extractDexFiles(oldArchivePath, dexPaths);
final newDexBytes = _extractDexFiles(newArchivePath, dexPaths);
const parser = DexParser();
const differ = DexDiffer();
final safePaths = <String>{};
final dexDiffResults = <String, DexDiffResult>{};
for (final path in dexPaths) {
final oldBytes = oldDexBytes[path];
final newBytes = newDexBytes[path];
if (oldBytes == null || newBytes == null) continue;
try {
final oldDex = parser.parse(oldBytes);
final newDex = parser.parse(newBytes);
final result = differ.diff(oldDex, newDex);
if (result.isSafe) {
safePaths.add(path);
} else {
dexDiffResults[path] = result;
}
// Catch all exceptions so unparseable DEX files are conservatively
// treated as changed rather than crashing the diff.
// ignore: avoid_catches_without_on_clauses
} catch (_) {
// If parsing fails, conservatively keep the path as changed.
}
}
return AndroidFileSetDiff(
addedPaths: fileSetDiff.addedPaths,
removedPaths: fileSetDiff.removedPaths,
changedPaths: fileSetDiff.changedPaths.difference(safePaths),
dexDiffResults: dexDiffResults,
);
}
Map<String, Uint8List> _extractDexFiles(
String archivePath,
List<String> paths,
) {
final pathSet = paths.toSet();
final result = <String, Uint8List>{};
final archive = ZipDecoder().decodeStream(
InputFileStream(archivePath),
);
for (final file in archive.files) {
if (file.isFile && pathSet.contains(file.name)) {
result[file.name] = Uint8List.fromList(file.content);
}
}
return result;
}
@override
bool isAssetFilePath(String filePath) {
const assetDirNames = ['assets', 'res'];
@@ -1,7 +1,9 @@
// cspell:words dexdump
import 'dart:io';
import 'package:mason_logger/mason_logger.dart';
import 'package:scoped_deps/scoped_deps.dart';
import 'package:shorebird_cli/src/archive_analysis/android_archive_differ.dart';
import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart';
import 'package:shorebird_cli/src/logging/logging.dart';
import 'package:shorebird_cli/src/shorebird_documentation.dart';
@@ -80,14 +82,35 @@ class PatchDiffChecker {
yellow.wrap(
archiveDiffer.nativeFileSetDiff(contentDiffs).prettyString,
),
)
..info(
yellow.wrap(
'''
);
If you don't know why you're seeing this error, visit our troubleshooting page at ${nativeChangesTroubleshootingUrl.toLink()}''',
// Show detailed DEX diff information if available.
if (contentDiffs is AndroidFileSetDiff &&
contentDiffs.dexDiffResults.isNotEmpty) {
for (final dexPath
in archiveDiffer
.nativeFileSetDiff(contentDiffs)
.changedPaths
.where((p) => p.endsWith('.dex'))) {
final dexResult = contentDiffs.dexDiffResults[dexPath];
if (dexResult != null) {
logger.info(yellow.wrap(dexResult.describe()));
}
}
logger.info(
yellow.wrap(
'\nFor detailed DEX disassembly, run: dexdump -d <file>',
),
);
}
logger.info(
yellow.wrap(
'''
If you don't know why you're seeing this error, visit our troubleshooting page at ${nativeChangesTroubleshootingUrl.toLink()}''',
),
);
if (!allowNativeChanges) {
if (!shorebirdEnv.canAcceptUserInput) {
+2
View File
@@ -18,6 +18,8 @@ dependencies:
clock: ^1.1.2
collection: ^1.19.1
crypto: ^3.0.6
dex:
path: ../dex
equatable: ^2.0.7
googleapis_auth: ^2.0.0
http: ^1.5.0
Binary file not shown.
@@ -60,6 +60,58 @@ void main() {
'META-INF/MANIFEST.MF',
});
});
test('filters out DEX files with only path differences', () async {
final baseDexAabPath = p.join(
aabFixturesBasePath,
'base_dex_test.aab',
);
final pathOnlyAabPath = p.join(
aabFixturesBasePath,
'changed_dex_path_only.aab',
);
final fileSetDiff = await differ.changedFiles(
baseDexAabPath,
pathOnlyAabPath,
);
// DEX file should be filtered out since only source paths differ.
expect(
fileSetDiff.changedPaths.where((p) => p.endsWith('.dex')).isEmpty,
isTrue,
);
expect(
differ.containsPotentiallyBreakingNativeDiffs(fileSetDiff),
isFalse,
);
});
test('keeps DEX files with structural changes', () async {
final baseDexAabPath = p.join(
aabFixturesBasePath,
'base_dex_test.aab',
);
final methodAddedAabPath = p.join(
aabFixturesBasePath,
'changed_dex_method_added.aab',
);
final fileSetDiff = await differ.changedFiles(
baseDexAabPath,
methodAddedAabPath,
);
// DEX file should remain since there are structural changes.
expect(
fileSetDiff.changedPaths
.where((p) => p.endsWith('.dex'))
.isNotEmpty,
isTrue,
);
expect(
differ.containsPotentiallyBreakingNativeDiffs(fileSetDiff),
isTrue,
);
});
});
group('contentDifferences', () {
+1
View File
@@ -4,6 +4,7 @@ environment:
sdk: ^3.9.0
workspace:
- packages/artifact_proxy
- packages/dex
- packages/discord_gcp_alerts
- packages/flutter_version_resolver
- packages/jwt