Skip to content

Commit 5ad966e

Browse files
sunnylqmclaude
andcommitted
feat(package): keep resources.arsc in slim APKs
Slimming drops resources.arsc, and with it the only map from a resource name and density to its (usually shortened) path. Keep it, deflated since APKs store it raw, so the server can tell which APK file holds a hot update's image. It stays out of resolveNativePackageEntry: it is never a diff origin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent bac8022 commit 5ad966e

2 files changed

Lines changed: 106 additions & 3 deletions

File tree

‎src/native-package.ts‎

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
import fs from 'fs';
22
import os from 'os';
33
import path from 'path';
4+
import { Readable } from 'stream';
5+
import { promisify } from 'util';
46
import {
57
type Entry,
68
open as openZipFile,
79
type ZipFile as YauzlZipFile,
810
} from 'yauzl';
11+
import zlib from 'zlib';
912
import type { Platform } from './types';
1013
import { ensureDir, remove } from './utils/fs-helpers';
1114
import {
1215
createOutputZip,
1316
openEntryStream,
17+
readEntry,
1418
writeEntry,
1519
} from './utils/zip-entries';
1620
import { fitsRawZip, RawZipWriter } from './utils/zip-raw-writer';
@@ -72,9 +76,22 @@ export function resolveNativePackageEntry(
7276
return nativePackageRules[platform].resolveEntry(entryName);
7377
}
7478

79+
/**
80+
* Entries kept for the server only: it maps resource names to their (shortened)
81+
* paths through resources.arsc when building a package diff. They are never a
82+
* diff origin, so they stay out of resolveNativePackageEntry.
83+
*/
84+
const serverOnlyEntries: Partial<Record<Platform, string>> = {
85+
android: 'resources.arsc',
86+
};
87+
88+
const deflateRaw = promisify(zlib.deflateRaw);
89+
7590
/** Where the kept entries go: a raw copy (default) or a yazl repack. */
7691
interface SlimSink {
7792
copyEntry(sourceZip: YauzlZipFile, entry: Entry): Promise<void>;
93+
/** like copyEntry, but an entry the source stores uncompressed is deflated */
94+
copyEntryDeflated(sourceZip: YauzlZipFile, entry: Entry): Promise<void>;
7895
/** add a local file under `fileName`, dated like `like` */
7996
addFile(filePath: string, fileName: string, like: Entry): Promise<void>;
8097
end(): Promise<void>;
@@ -97,6 +114,27 @@ class RawCopySink implements SlimSink {
97114
});
98115
await this.writer.addRawEntry(entry, readStream);
99116
}
117+
async copyEntryDeflated(
118+
sourceZip: YauzlZipFile,
119+
entry: Entry,
120+
): Promise<void> {
121+
if (entry.compressionMethod !== 0) {
122+
return this.copyEntry(sourceZip, entry);
123+
}
124+
const deflated = await deflateRaw(await readEntry(entry, sourceZip), {
125+
level: zlib.constants.Z_BEST_COMPRESSION,
126+
});
127+
await this.writer.addRawEntry(
128+
{
129+
...entry,
130+
compressionMethod: 8,
131+
compressedSize: deflated.length,
132+
// deflate strength bits of a stored entry mean nothing
133+
generalPurposeBitFlag: 0,
134+
},
135+
Readable.from([deflated]),
136+
);
137+
}
100138
addFile(filePath: string, fileName: string, like: Entry): Promise<void> {
101139
// a nested slim archive is a zip of deflated entries: storing it as-is
102140
// loses nothing and skips another pass over its bytes
@@ -116,13 +154,23 @@ class RepackSink implements SlimSink {
116154
constructor(output: string) {
117155
this.output = createOutputZip(output);
118156
}
119-
async copyEntry(sourceZip: YauzlZipFile, entry: Entry): Promise<void> {
157+
copyEntry(sourceZip: YauzlZipFile, entry: Entry): Promise<void> {
158+
return this.add(sourceZip, entry, entry.compressionMethod !== 0);
159+
}
160+
copyEntryDeflated(sourceZip: YauzlZipFile, entry: Entry): Promise<void> {
161+
return this.add(sourceZip, entry, true);
162+
}
163+
private async add(
164+
sourceZip: YauzlZipFile,
165+
entry: Entry,
166+
compress: boolean,
167+
): Promise<void> {
120168
const readStream = await openEntryStream(entry, sourceZip);
121169
await new Promise<void>((resolve, reject) => {
122170
readStream.once('error', reject);
123171
readStream.once('end', () => resolve());
124172
this.output.zipfile.addReadStream(readStream, entry.fileName, {
125-
compress: entry.compressionMethod !== 0,
173+
compress,
126174
mtime: entry.getLastModDate(),
127175
});
128176
});
@@ -248,6 +296,9 @@ async function filterNativePackageArchive(
248296
if (resolvedEntry.kind === 'bundle') {
249297
includedBundles += 1;
250298
}
299+
} else if (entry.fileName === serverOnlyEntries[platform]) {
300+
await sink.copyEntryDeflated(sourceZip, entry);
301+
includedEntries += 1;
251302
}
252303
sourceZip.readEntry();
253304
} catch (error) {
@@ -270,7 +321,7 @@ async function filterNativePackageArchive(
270321

271322
/**
272323
* Repack a native package with only the bundle and resources required by
273-
* package-origin diffs. Entry names and nested Harmony HAP structure are kept.
324+
* package-origin diffs, plus the server-only entries. Entry names and nested Harmony HAP structure are kept.
274325
*/
275326
export async function createSlimNativePackage(
276327
source: string,

‎tests/native-package.test.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,29 @@ describe('native package baseline extraction', () => {
9797
'assets/data/config.json',
9898
'assets/index.android.bundle',
9999
'res/drawable-xhdpi-v4/icon.webp',
100+
'resources.arsc',
100101
]);
101102
expect(files['assets/index.android.bundle'].toString()).toBe('bundle');
103+
expect(files['resources.arsc'].toString()).toBe('resource-table');
104+
});
105+
106+
test('resources.arsc is kept for the server but is not a diff origin', () => {
107+
expect(resolveNativePackageEntry('android', 'resources.arsc')).toBe(
108+
undefined,
109+
);
110+
});
111+
112+
test('keeps resources.arsc only for Android', async () => {
113+
const source = path.join(tempRoot, 'source.ipa');
114+
const output = path.join(tempRoot, 'slim.ipa');
115+
await createZip(source, {
116+
'Payload/Test.app/main.jsbundle': 'bundle',
117+
'resources.arsc': 'resource-table',
118+
});
119+
await createSlimNativePackage(source, output, 'ios');
120+
expect(Object.keys(await readZipFiles(output))).toEqual([
121+
'Payload/Test.app/main.jsbundle',
122+
]);
102123
});
103124

104125
test('keeps IPA paths while dropping native executables and frameworks', async () => {
@@ -256,4 +277,35 @@ describe('raw-copy slim packages', () => {
256277
]);
257278
expect(files['assets/stored.bin'].equals(Buffer.alloc(4096, 7))).toBe(true);
258279
});
280+
281+
for (const repack of [false, true]) {
282+
test(`deflates a stored resources.arsc (repack: ${repack})`, async () => {
283+
const source = path.join(tempRoot, 'source.apk');
284+
const output = path.join(tempRoot, 'slim.apk');
285+
const table = Buffer.from('resource-table '.repeat(4000));
286+
await new Promise<void>((resolve, reject) => {
287+
const zip = new YazlZipFile();
288+
zip.outputStream
289+
.pipe(fs.createWriteStream(source))
290+
.once('error', reject)
291+
.once('close', () => resolve());
292+
zip.addBuffer(Buffer.from('bundle'), 'assets/index.android.bundle');
293+
zip.addBuffer(table, 'resources.arsc', { compress: false });
294+
zip.end();
295+
});
296+
await createSlimNativePackage(source, output, 'android', { repack });
297+
298+
const [before, after] = await Promise.all([
299+
entryFacts(source),
300+
entryFacts(output),
301+
]);
302+
expect(before['resources.arsc'].method).toBe(0);
303+
expect(after['resources.arsc'].method).toBe(8);
304+
expect(after['resources.arsc'].crc).toBe(before['resources.arsc'].crc);
305+
expect(after['resources.arsc'].usize).toBe(table.length);
306+
expect(after['resources.arsc'].csize).toBeLessThan(table.length);
307+
const files = await readZipFiles(output);
308+
expect(files['resources.arsc'].equals(table)).toBe(true);
309+
});
310+
}
259311
});

0 commit comments

Comments
 (0)