Skip to content

Commit fa44097

Browse files
sunnylqmclaude
andcommitted
feat(diff): emit copiesCrc so clients can match copied resources by content
When the uploaded baseline is an APK but the app is installed from an AAB (Play split APKs), res/ drawable paths are shortened on device, so the path recorded in `copies` (e.g. res/drawable-xhdpi-v4/x.webp) does not exist verbatim and images (webp) fail to copy during a from-package patch. diffFromPackage already finds unchanged/moved files by CRC32 internally but only hands the client a path. This adds an optional `copiesCrc` map to __diff.json ({ to: crc32 }) for "moved" entries (the res/ resources at risk of path divergence), letting the client locate them by content when the path is missing. CRC32 is over the uncompressed content, so it is stable across APK/AAB packaging. - Only moved entries get a crc (same-path assets stay path-stable) → minimal manifest growth. - Keyed by `to` (unique) to avoid same-content collisions. - Fully backward compatible: old clients ignore the field; new clients treat its absence as today's path-based behavior. Consumed by react-native-update (Android BundledResourceCopier). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 78cc077 commit fa44097

2 files changed

Lines changed: 71 additions & 1 deletion

File tree

src/diff.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,13 @@ async function diffFromPackage(
223223

224224
let originSource: Buffer | undefined;
225225

226+
// Content checksum (CRC32) for entries that are copied from a *different*
227+
// path in the origin package ("moved" entries). On Android these are the
228+
// res/ drawables (images), whose on-device path differs between an APK
229+
// baseline and an AAB(split-apk) install due to resource path shortening,
230+
// so the client cannot locate them by path and must fall back to content.
231+
const copiesCrc: Record<string, number> = {};
232+
226233
await enumZipEntries(origin, async (entry, zipFile) => {
227234
if (!/\/$/.test(entry.fileName)) {
228235
const fn = transformPackagePath(entry.fileName);
@@ -281,6 +288,10 @@ async function diffFromPackage(
281288
const movedFrom = originMap[entry.crc32];
282289
if (movedFrom) {
283290
copies[entry.fileName] = movedFrom;
291+
// Record the content checksum so the client can locate this file by
292+
// content when the origin path does not exist verbatim on device
293+
// (APK baseline -> AAB install path shortening).
294+
copiesCrc[entry.fileName] = entry.crc32;
284295
return;
285296
}
286297

@@ -313,7 +324,7 @@ async function diffFromPackage(
313324
}
314325
});
315326

316-
const diffManifest = Buffer.from(JSON.stringify({ copies }));
327+
const diffManifest = Buffer.from(JSON.stringify({ copies, copiesCrc }));
317328
zipfile.addBuffer(
318329
diffManifest,
319330
'__diff.json',

tests/diff.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,65 @@ describe('diff commands', () => {
401401
expect(result.files['assets/new.txt']?.toString('utf-8')).toBe('new-file');
402402
});
403403

404+
test('hdiffFromApk emits copiesCrc for moved (res/) entries only', async () => {
405+
const originPath = path.join(tempRoot, 'origin-crc.apk');
406+
const nextPath = path.join(tempRoot, 'next-crc.ppk');
407+
const outputPath = path.join(tempRoot, 'out', 'apk-crc-diff.ppk');
408+
409+
const imageContent = Buffer.concat([
410+
Buffer.from('RIFF'),
411+
Buffer.from([0x10, 0x00, 0x00, 0x00]),
412+
Buffer.from('WEBPVP8 image-bytes'),
413+
]);
414+
415+
// origin (APK layout): image lives under res/drawable-*-v4 with a full
416+
// readable path; an asset under assets/ keeps a stable path.
417+
await createZip(originPath, {
418+
'assets/index.android.bundle': 'old-bundle',
419+
'res/drawable-xhdpi-v4/x.webp': imageContent,
420+
'assets/keep.txt': 'keep-content',
421+
});
422+
// next (ppk layout from --assets-dest): image is at root drawable-* (moved),
423+
// the asset keeps its assets/ path (same content -> same path).
424+
await createZip(nextPath, {
425+
'index.bundlejs': 'new-bundle',
426+
'drawable-xhdpi/x.webp': imageContent,
427+
'assets/keep.txt': 'keep-content',
428+
});
429+
430+
await diffCommands.hdiffFromApk(
431+
createContext([originPath, nextPath], {
432+
output: outputPath,
433+
customDiff: () => Buffer.from('patch'),
434+
}),
435+
);
436+
437+
// crc32 of the image as stored in the origin package
438+
let originImageCrc = -1;
439+
await enumZipEntries(originPath, async (entry) => {
440+
if (entry.fileName === 'res/drawable-xhdpi-v4/x.webp') {
441+
originImageCrc = entry.crc32;
442+
}
443+
});
444+
445+
const result = await readZipContent(outputPath);
446+
const diffMeta = JSON.parse(
447+
result.files['__diff.json'].toString('utf-8'),
448+
) as {
449+
copies: Record<string, string>;
450+
copiesCrc: Record<string, number>;
451+
};
452+
453+
// moved res/ image -> path recorded in copies, crc recorded in copiesCrc
454+
expect(diffMeta.copies['drawable-xhdpi/x.webp']).toBe(
455+
'res/drawable-xhdpi-v4/x.webp',
456+
);
457+
expect(diffMeta.copiesCrc['drawable-xhdpi/x.webp']).toBe(originImageCrc);
458+
// same-path asset -> no crc needed (efficiency: only moved entries get one)
459+
expect(diffMeta.copies['assets/keep.txt']).toBe('');
460+
expect(diffMeta.copiesCrc['assets/keep.txt']).toBeUndefined();
461+
});
462+
404463
test('hdiffFromIpa ignores non-payload files when resolving origin package path', async () => {
405464
const originPath = path.join(tempRoot, 'origin-non-payload.ipa');
406465
const nextPath = path.join(tempRoot, 'next-non-payload.ppk');

0 commit comments

Comments
 (0)