From fa44097fb50e89fca08be475367514bb7125fb1e Mon Sep 17 00:00:00 2001 From: sunnylqm Date: Tue, 9 Jun 2026 16:45:36 +0800 Subject: [PATCH] feat(diff): emit copiesCrc so clients can match copied resources by content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/diff.ts | 13 +++++++++- tests/diff.test.ts | 59 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/diff.ts b/src/diff.ts index f871e1f..4aa6d32 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -223,6 +223,13 @@ async function diffFromPackage( let originSource: Buffer | undefined; + // Content checksum (CRC32) for entries that are copied from a *different* + // path in the origin package ("moved" entries). On Android these are the + // res/ drawables (images), whose on-device path differs between an APK + // baseline and an AAB(split-apk) install due to resource path shortening, + // so the client cannot locate them by path and must fall back to content. + const copiesCrc: Record = {}; + await enumZipEntries(origin, async (entry, zipFile) => { if (!/\/$/.test(entry.fileName)) { const fn = transformPackagePath(entry.fileName); @@ -281,6 +288,10 @@ async function diffFromPackage( const movedFrom = originMap[entry.crc32]; if (movedFrom) { copies[entry.fileName] = movedFrom; + // Record the content checksum so the client can locate this file by + // content when the origin path does not exist verbatim on device + // (APK baseline -> AAB install path shortening). + copiesCrc[entry.fileName] = entry.crc32; return; } @@ -313,7 +324,7 @@ async function diffFromPackage( } }); - const diffManifest = Buffer.from(JSON.stringify({ copies })); + const diffManifest = Buffer.from(JSON.stringify({ copies, copiesCrc })); zipfile.addBuffer( diffManifest, '__diff.json', diff --git a/tests/diff.test.ts b/tests/diff.test.ts index e00cc89..1bfb20d 100644 --- a/tests/diff.test.ts +++ b/tests/diff.test.ts @@ -401,6 +401,65 @@ describe('diff commands', () => { expect(result.files['assets/new.txt']?.toString('utf-8')).toBe('new-file'); }); + test('hdiffFromApk emits copiesCrc for moved (res/) entries only', async () => { + const originPath = path.join(tempRoot, 'origin-crc.apk'); + const nextPath = path.join(tempRoot, 'next-crc.ppk'); + const outputPath = path.join(tempRoot, 'out', 'apk-crc-diff.ppk'); + + const imageContent = Buffer.concat([ + Buffer.from('RIFF'), + Buffer.from([0x10, 0x00, 0x00, 0x00]), + Buffer.from('WEBPVP8 image-bytes'), + ]); + + // origin (APK layout): image lives under res/drawable-*-v4 with a full + // readable path; an asset under assets/ keeps a stable path. + await createZip(originPath, { + 'assets/index.android.bundle': 'old-bundle', + 'res/drawable-xhdpi-v4/x.webp': imageContent, + 'assets/keep.txt': 'keep-content', + }); + // next (ppk layout from --assets-dest): image is at root drawable-* (moved), + // the asset keeps its assets/ path (same content -> same path). + await createZip(nextPath, { + 'index.bundlejs': 'new-bundle', + 'drawable-xhdpi/x.webp': imageContent, + 'assets/keep.txt': 'keep-content', + }); + + await diffCommands.hdiffFromApk( + createContext([originPath, nextPath], { + output: outputPath, + customDiff: () => Buffer.from('patch'), + }), + ); + + // crc32 of the image as stored in the origin package + let originImageCrc = -1; + await enumZipEntries(originPath, async (entry) => { + if (entry.fileName === 'res/drawable-xhdpi-v4/x.webp') { + originImageCrc = entry.crc32; + } + }); + + const result = await readZipContent(outputPath); + const diffMeta = JSON.parse( + result.files['__diff.json'].toString('utf-8'), + ) as { + copies: Record; + copiesCrc: Record; + }; + + // moved res/ image -> path recorded in copies, crc recorded in copiesCrc + expect(diffMeta.copies['drawable-xhdpi/x.webp']).toBe( + 'res/drawable-xhdpi-v4/x.webp', + ); + expect(diffMeta.copiesCrc['drawable-xhdpi/x.webp']).toBe(originImageCrc); + // same-path asset -> no crc needed (efficiency: only moved entries get one) + expect(diffMeta.copies['assets/keep.txt']).toBe(''); + expect(diffMeta.copiesCrc['assets/keep.txt']).toBeUndefined(); + }); + test('hdiffFromIpa ignores non-payload files when resolving origin package path', async () => { const originPath = path.join(tempRoot, 'origin-non-payload.ipa'); const nextPath = path.join(tempRoot, 'next-non-payload.ppk');