fix(mobile): APK starts on Android 15+ 16 KB page devices and emulators
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 2m56s
release / android (APK в релиз) (push) Successful in 4m53s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 3m27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m2s
ci / ts / ts (packages + web) (push) Successful in 1m48s
release / release (версия и заметки) (push) Successful in 8s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 2m56s
release / android (APK в релиз) (push) Successful in 4m53s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 3m27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m2s
ci / ts / ts (packages + web) (push) Successful in 1m48s
release / release (версия и заметки) (push) Successful in 8s
On an Android 16 emulator the release APK crashed at launch with "Cannot read property 'NativeModule' of undefined": libexpo-modules-core.so was missing for the ABI the device picked. Two build defects: - librnskia.so was linked with 4 KB segment alignment (Skia 2.0.0-next.4 lacks ANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES), so 16 KB page devices cannot load it; a pnpm patch adds the flag - the APK carried incomplete x86, x86_64 and armeabi-v7a folders from the React Native libraries; an Expo config plugin keeps only arm64-v8a, the ABI the native modules are built for (the APK also halves in size) scripts/check-apk.mjs now fails the release on another ABI, a missing core library or a library not aligned to 16 KB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
66f5749fc1
commit
73e642c1ec
11 files changed
+217
-3
No files matched your search
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env node
|
||||
// Проверка собранного APK перед публикацией (release.yml):
|
||||
// - нативные библиотеки только под arm64-v8a: неполный каталог другой ABI роняет приложение при старте
|
||||
// («couldn't find DSO to load: libexpo-modules-core.so» на x86-эмуляторе);
|
||||
// - есть библиотеки, без которых приложение не запустится;
|
||||
// - PT_LOAD-сегменты каждой .so выровнены по 16 КБ: иначе на устройствах с 16 КБ страницами памяти
|
||||
// (Android 15+) библиотека не загружается и приложение закрывается сразу после запуска.
|
||||
// Запуск: node scripts/check-apk.mjs app-release.apk
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { inflateRawSync } from 'node:zlib';
|
||||
|
||||
export const ABI = 'arm64-v8a';
|
||||
export const REQUIRED = ['libreactnative.so', 'libhermes.so', 'libexpo-modules-core.so', 'librnskia.so', 'libreanimated.so'];
|
||||
const PAGE = 16384;
|
||||
|
||||
/** Файлы ZIP по центральному каталогу: имя → содержимое. */
|
||||
export function zipEntries(buf) {
|
||||
let eocd = buf.length - 22;
|
||||
while (eocd >= 0 && buf.readUInt32LE(eocd) !== 0x06054b50) eocd--;
|
||||
if (eocd < 0) throw new Error('not a zip file');
|
||||
const count = buf.readUInt16LE(eocd + 10);
|
||||
let p = buf.readUInt32LE(eocd + 16);
|
||||
const out = new Map();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const method = buf.readUInt16LE(p + 10);
|
||||
const size = buf.readUInt32LE(p + 20);
|
||||
const nameLen = buf.readUInt16LE(p + 28);
|
||||
const extraLen = buf.readUInt16LE(p + 30);
|
||||
const commentLen = buf.readUInt16LE(p + 32);
|
||||
const local = buf.readUInt32LE(p + 42);
|
||||
const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
|
||||
out.set(name, () => {
|
||||
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
|
||||
const raw = buf.subarray(start, start + size);
|
||||
return method === 0 ? raw : inflateRawSync(raw);
|
||||
});
|
||||
p += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Наименьшее выравнивание PT_LOAD-сегментов 64-битной ELF-библиотеки. */
|
||||
export function loadAlignment(elf) {
|
||||
if (elf.readUInt32BE(0) !== 0x7f454c46 || elf[4] !== 2) throw new Error('not a 64-bit ELF');
|
||||
const phoff = Number(elf.readBigUInt64LE(0x20));
|
||||
const phentsize = elf.readUInt16LE(0x36);
|
||||
const phnum = elf.readUInt16LE(0x38);
|
||||
let min = Infinity;
|
||||
for (let i = 0; i < phnum; i++) {
|
||||
const off = phoff + i * phentsize;
|
||||
if (elf.readUInt32LE(off) === 1) min = Math.min(min, Number(elf.readBigUInt64LE(off + 0x30)));
|
||||
}
|
||||
return min;
|
||||
}
|
||||
|
||||
export function checkApk(buf) {
|
||||
const problems = [];
|
||||
const entries = zipEntries(buf);
|
||||
const libs = [...entries.keys()].filter((n) => n.startsWith('lib/') && n.endsWith('.so'));
|
||||
const abis = new Set(libs.map((n) => n.split('/')[1]));
|
||||
for (const abi of abis) if (abi !== ABI) problems.push(`лишняя ABI в APK: lib/${abi}/`);
|
||||
const mine = new Set(libs.filter((n) => n.startsWith(`lib/${ABI}/`)).map((n) => n.split('/')[2]));
|
||||
for (const lib of REQUIRED) if (!mine.has(lib)) problems.push(`нет lib/${ABI}/${lib}`);
|
||||
for (const name of libs.filter((n) => n.startsWith(`lib/${ABI}/`))) {
|
||||
const align = loadAlignment(entries.get(name)());
|
||||
if (align < PAGE) problems.push(`${name}: сегменты выровнены по ${align} байт, нужно ${PAGE} (16 КБ страницы)`);
|
||||
}
|
||||
return { libs: mine.size, problems };
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('check-apk.mjs')) {
|
||||
const file = process.argv[2];
|
||||
if (!file) {
|
||||
console.error('usage: node scripts/check-apk.mjs <apk>');
|
||||
process.exit(2);
|
||||
}
|
||||
const { libs, problems } = checkApk(readFileSync(file));
|
||||
if (problems.length) {
|
||||
for (const p of problems) console.error(`✗ ${p}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`APK: ${libs} библиотек ${ABI}, все выровнены по 16 КБ`);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import { checkApk, loadAlignment, REQUIRED } from './check-apk.mjs';
|
||||
|
||||
/** ELF64 с одним PT_LOAD и заданным выравниванием. */
|
||||
function elf(align) {
|
||||
const b = Buffer.alloc(64 + 56);
|
||||
b.writeUInt32BE(0x7f454c46, 0);
|
||||
b[4] = 2;
|
||||
b.writeBigUInt64LE(64n, 0x20);
|
||||
b.writeUInt16LE(56, 0x36);
|
||||
b.writeUInt16LE(1, 0x38);
|
||||
b.writeUInt32LE(1, 64);
|
||||
b.writeBigUInt64LE(BigInt(align), 64 + 0x30);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** ZIP без сжатия из пар [имя, содержимое]. */
|
||||
function zip(files) {
|
||||
const locals = [];
|
||||
const central = [];
|
||||
let offset = 0;
|
||||
for (const [name, data] of files) {
|
||||
const n = Buffer.from(name);
|
||||
const lh = Buffer.alloc(30);
|
||||
lh.writeUInt32LE(0x04034b50, 0);
|
||||
lh.writeUInt32LE(data.length, 18);
|
||||
lh.writeUInt32LE(data.length, 22);
|
||||
lh.writeUInt16LE(n.length, 26);
|
||||
locals.push(lh, n, data);
|
||||
const ch = Buffer.alloc(46);
|
||||
ch.writeUInt32LE(0x02014b50, 0);
|
||||
ch.writeUInt32LE(data.length, 20);
|
||||
ch.writeUInt32LE(data.length, 24);
|
||||
ch.writeUInt16LE(n.length, 28);
|
||||
ch.writeUInt32LE(offset, 42);
|
||||
central.push(ch, n);
|
||||
offset += 30 + n.length + data.length;
|
||||
}
|
||||
const cd = Buffer.concat(central);
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
end.writeUInt16LE(files.length, 8);
|
||||
end.writeUInt16LE(files.length, 10);
|
||||
end.writeUInt32LE(cd.length, 12);
|
||||
end.writeUInt32LE(offset, 16);
|
||||
return Buffer.concat([...locals, cd, end]);
|
||||
}
|
||||
|
||||
test('reads the smallest PT_LOAD alignment', () => {
|
||||
assert.equal(loadAlignment(elf(16384)), 16384);
|
||||
assert.equal(loadAlignment(elf(4096)), 4096);
|
||||
});
|
||||
|
||||
test('accepts an arm64-only APK aligned to 16 KB', () => {
|
||||
const apk = zip(REQUIRED.map((l) => [`lib/arm64-v8a/${l}`, elf(16384)]));
|
||||
assert.deepEqual(checkApk(apk).problems, []);
|
||||
});
|
||||
|
||||
test('rejects other ABIs, missing libraries and 4 KB alignment', () => {
|
||||
const libs = REQUIRED.filter((l) => l !== 'libexpo-modules-core.so').map((l) => [
|
||||
`lib/arm64-v8a/${l}`,
|
||||
elf(l === 'librnskia.so' ? 4096 : 16384),
|
||||
]);
|
||||
const { problems } = checkApk(zip([...libs, ['lib/x86_64/libjsi.so', elf(16384)]]));
|
||||
assert.equal(problems.length, 3);
|
||||
assert.match(problems.join('\n'), /lib\/x86_64/);
|
||||
assert.match(problems.join('\n'), /libexpo-modules-core\.so/);
|
||||
assert.match(problems.join('\n'), /librnskia\.so.*4096/);
|
||||
});
|
||||
Reference in new issue
Block a user