Skip to content

Commit 22ccd73

Browse files
committed
use node bundletool
1 parent 8e4b9c4 commit 22ccd73

File tree

1 file changed

+67
-238
lines changed
  • src/utils/app-info-parser

1 file changed

+67
-238
lines changed

src/utils/app-info-parser/aab.ts

Lines changed: 67 additions & 238 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,10 @@ import fs from 'fs-extra';
22
import path from 'path';
33
import os from 'os';
44
import {
5-
type Entry,
6-
fromBuffer as openZipFromBuffer,
75
open as openZipFile,
86
} from 'yauzl';
9-
import { ZipFile as YazlZipFile } from 'yazl';
107
import Zip from './zip';
118

12-
interface ExtractApkOptions {
13-
includeAllSplits?: boolean;
14-
splits?: string[] | null;
15-
}
16-
17-
type BufferedEntry = {
18-
apkPath: string;
19-
buffer: Buffer;
20-
compress?: boolean;
21-
};
22-
23-
type SplitEntry = {
24-
name: string;
25-
buffer: Buffer;
26-
};
27-
28-
/**
29-
* 纯 JS 的 AAB 解析器,参考 https://github.com/accrescent/android-bundle
30-
* 将 base/ 内容重打包为一个通用 APK,并在需要时合并 split APK。
31-
* 生成的 APK 使用 AAB 中的 proto manifest/resources(不可用于安装,但可被本工具解析)。
32-
*/
339
class AabParser extends Zip {
3410
file: string | File;
3511

@@ -41,33 +17,75 @@ class AabParser extends Zip {
4117
/**
4218
* 从 AAB 提取 APK(不依赖 bundletool)
4319
*/
44-
async extractApk(
45-
outputPath: string,
46-
options: ExtractApkOptions = {},
47-
): Promise<string> {
48-
if (typeof this.file !== 'string') {
49-
throw new Error('AAB file path must be a string in Node.js environment');
50-
}
20+
async extractApk(outputPath: string) {
21+
const { exec } = require('child_process');
22+
const util = require('util');
23+
const execAsync = util.promisify(exec);
5124

52-
const { includeAllSplits = false, splits = null } = options;
53-
const { baseEntries, splitEntries, metaInfEntries } =
54-
await this.collectBundleEntries();
25+
// Create a temp file for the .apks output
26+
const tempDir = os.tmpdir();
27+
const tempApksPath = path.join(tempDir, `temp-${Date.now()}.apks`);
5528

56-
const entryMap = new Map<string, BufferedEntry>();
57-
for (const entry of baseEntries) {
58-
entryMap.set(entry.apkPath, entry);
59-
}
60-
for (const entry of metaInfEntries) {
61-
entryMap.set(entry.apkPath, entry);
62-
}
29+
try {
30+
// 1. Build APKS (universal mode)
31+
// We assume bundletool is in the path.
32+
// User might need keystore to sign it properly but for simple extraction we stick to default debug key if possible or unsigned?
33+
// actually bundletool build-apks signs with debug key by default if no keystore provided.
6334

64-
const selectedSplits = this.pickSplits(splitEntries, includeAllSplits, splits);
65-
for (const split of selectedSplits) {
66-
await this.mergeSplitApk(entryMap, split.buffer);
67-
}
35+
let cmd = `bundletool build-apks --mode=universal --bundle="${this.file}" --output="${tempApksPath}" --overwrite`;
36+
try {
37+
await execAsync(cmd);
38+
} catch (e) {
39+
// Fallback to npx node-bundletool if bundletool is not in PATH
40+
// We use -y to avoid interactive prompt for installation
41+
cmd = `npx -y node-bundletool build-apks --mode=universal --bundle="${this.file}" --output="${tempApksPath}" --overwrite`;
42+
await execAsync(cmd);
43+
}
6844

69-
await this.writeApk(entryMap, outputPath);
70-
return outputPath;
45+
// 2. Extract universal.apk from the .apks (zip) file
46+
await new Promise<void>((resolve, reject) => {
47+
openZipFile(tempApksPath, { lazyEntries: true }, (err, zipfile) => {
48+
if (err || !zipfile) {
49+
reject(err || new Error('Failed to open generated .apks file'));
50+
return;
51+
}
52+
53+
let found = false;
54+
zipfile.readEntry();
55+
zipfile.on('entry', (entry) => {
56+
if (entry.fileName === 'universal.apk') {
57+
found = true;
58+
zipfile.openReadStream(entry, (err, readStream) => {
59+
if (err || !readStream) {
60+
reject(err || new Error('Failed to read universal.apk'));
61+
return;
62+
}
63+
const writeStream = fs.createWriteStream(outputPath);
64+
readStream.pipe(writeStream);
65+
writeStream.on('close', () => {
66+
zipfile.close();
67+
resolve();
68+
});
69+
writeStream.on('error', reject);
70+
});
71+
} else {
72+
zipfile.readEntry();
73+
}
74+
});
75+
76+
zipfile.on('end', () => {
77+
if (!found)
78+
reject(new Error('universal.apk not found in generated .apks'));
79+
});
80+
zipfile.on('error', reject);
81+
});
82+
});
83+
} finally {
84+
// Cleanup
85+
if (await fs.pathExists(tempApksPath)) {
86+
await fs.remove(tempApksPath);
87+
}
88+
}
7189
}
7290

7391
/**
@@ -110,195 +128,6 @@ class AabParser extends Zip {
110128
throw new Error(`Failed to parse AAB: ${error.message ?? error}`);
111129
}
112130
}
113-
114-
private pickSplits(
115-
splitEntries: SplitEntry[],
116-
includeAllSplits: boolean,
117-
splits: string[] | null,
118-
) {
119-
if (splits && splits.length > 0) {
120-
return splitEntries.filter(({ name }) =>
121-
splits.some((s) => name.includes(s)),
122-
);
123-
}
124-
return includeAllSplits ? splitEntries : [];
125-
}
126-
127-
private async writeApk(
128-
entries: Map<string, BufferedEntry>,
129-
outputPath: string,
130-
) {
131-
await fs.ensureDir(path.dirname(outputPath));
132-
133-
const zipFile = new YazlZipFile();
134-
for (const { apkPath, buffer, compress } of entries.values()) {
135-
zipFile.addBuffer(buffer, apkPath, {
136-
compress,
137-
});
138-
}
139-
140-
await new Promise<void>((resolve, reject) => {
141-
zipFile.outputStream
142-
.pipe(fs.createWriteStream(outputPath))
143-
.on('close', resolve)
144-
.on('error', reject);
145-
zipFile.end();
146-
});
147-
}
148-
149-
private async collectBundleEntries() {
150-
return new Promise<{
151-
baseEntries: BufferedEntry[];
152-
splitEntries: SplitEntry[];
153-
metaInfEntries: BufferedEntry[];
154-
}>((resolve, reject) => {
155-
openZipFile(this.file as string, { lazyEntries: true }, (err, zipfile) => {
156-
if (err || !zipfile) {
157-
reject(err ?? new Error('Failed to open AAB file'));
158-
return;
159-
}
160-
161-
const baseEntries: BufferedEntry[] = [];
162-
const splitEntries: SplitEntry[] = [];
163-
const metaInfEntries: BufferedEntry[] = [];
164-
const promises: Promise<void>[] = [];
165-
166-
const readNext = () => zipfile.readEntry();
167-
168-
zipfile.on('entry', (entry: Entry) => {
169-
if (entry.fileName.endsWith('/')) {
170-
readNext();
171-
return;
172-
}
173-
174-
const promise = this.readEntryBuffer(zipfile, entry)
175-
.then((buffer) => {
176-
if (entry.fileName.startsWith('base/')) {
177-
const apkPath = this.mapBasePath(entry.fileName);
178-
if (apkPath) {
179-
baseEntries.push({
180-
apkPath,
181-
buffer,
182-
compress: entry.compressionMethod !== 0,
183-
});
184-
}
185-
} else if (
186-
(entry.fileName.startsWith('splits/') ||
187-
entry.fileName.startsWith('split/')) &&
188-
entry.fileName.endsWith('.apk')
189-
) {
190-
splitEntries.push({ name: entry.fileName, buffer });
191-
} else if (entry.fileName.startsWith('META-INF/')) {
192-
metaInfEntries.push({
193-
apkPath: entry.fileName,
194-
buffer,
195-
compress: entry.compressionMethod !== 0,
196-
});
197-
}
198-
})
199-
.catch((error) => {
200-
zipfile.close();
201-
reject(error);
202-
})
203-
.finally(readNext);
204-
205-
promises.push(promise);
206-
});
207-
208-
zipfile.once('error', reject);
209-
zipfile.once('end', () => {
210-
Promise.all(promises)
211-
.then(() => resolve({ baseEntries, splitEntries, metaInfEntries }))
212-
.catch(reject);
213-
});
214-
215-
readNext();
216-
});
217-
});
218-
}
219-
220-
private mapBasePath(fileName: string) {
221-
const relative = fileName.replace(/^base\//, '');
222-
if (!relative) return null;
223-
224-
if (relative === 'manifest/AndroidManifest.xml') {
225-
return 'androidmanifest.xml';
226-
}
227-
228-
if (relative.startsWith('root/')) {
229-
return relative.replace(/^root\//, '');
230-
}
231-
232-
if (relative === 'resources.pb') {
233-
return 'resources.pb';
234-
}
235-
236-
if (relative === 'resources.arsc') {
237-
return 'resources.arsc';
238-
}
239-
240-
return relative;
241-
}
242-
243-
private async mergeSplitApk(
244-
entryMap: Map<string, BufferedEntry>,
245-
splitBuffer: Buffer,
246-
) {
247-
await new Promise<void>((resolve, reject) => {
248-
openZipFromBuffer(splitBuffer, { lazyEntries: true }, (err, zipfile) => {
249-
if (err || !zipfile) {
250-
reject(err ?? new Error('Failed to open split APK'));
251-
return;
252-
}
253-
254-
const readNext = () => zipfile.readEntry();
255-
zipfile.on('entry', (entry: Entry) => {
256-
if (entry.fileName.endsWith('/')) {
257-
readNext();
258-
return;
259-
}
260-
if (entry.fileName.startsWith('META-INF/')) {
261-
readNext();
262-
return;
263-
}
264-
265-
this.readEntryBuffer(zipfile, entry)
266-
.then((buffer) => {
267-
entryMap.set(entry.fileName, {
268-
apkPath: entry.fileName,
269-
buffer,
270-
compress: entry.compressionMethod !== 0,
271-
});
272-
})
273-
.catch((error) => {
274-
zipfile.close();
275-
reject(error);
276-
})
277-
.finally(readNext);
278-
});
279-
280-
zipfile.once('error', reject);
281-
zipfile.once('end', resolve);
282-
readNext();
283-
});
284-
});
285-
}
286-
287-
private async readEntryBuffer(zipfile: any, entry: Entry): Promise<Buffer> {
288-
return new Promise((resolve, reject) => {
289-
zipfile.openReadStream(entry, (err: any, readStream: any) => {
290-
if (err || !readStream) {
291-
reject(err ?? new Error('Failed to open entry stream'));
292-
return;
293-
}
294-
const chunks: Buffer[] = [];
295-
readStream.on('data', (chunk: Buffer) => chunks.push(chunk));
296-
readStream.on('end', () => resolve(Buffer.concat(chunks)));
297-
readStream.on('error', reject);
298-
});
299-
});
300-
}
301-
302131
/**
303132
* Parse manifest
304133
* @param {Buffer} buffer // manifest file's buffer
@@ -317,7 +146,7 @@ class AabParser extends Zip {
317146
});
318147
return parser.parse();
319148
} catch (e: any) {
320-
throw new Error('Parse AndroidManifest.xml error: ' + e.message);
149+
throw new Error(`Parse AndroidManifest.xml error: ${e.message}`);
321150
}
322151
}
323152

@@ -330,7 +159,7 @@ class AabParser extends Zip {
330159
const ResourceFinder = require('./resource-finder');
331160
return new ResourceFinder().processResourceTable(buffer);
332161
} catch (e: any) {
333-
throw new Error('Parser resources.arsc error: ' + e.message);
162+
throw new Error(`Parser resources.arsc error: ${e.message}`);
334163
}
335164
}
336165
}

0 commit comments

Comments
 (0)