From 73e642c1ecd26ee44a2161851118d315e26cc3f9 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:22:13 +0300 Subject: [PATCH] fix(mobile): APK starts on Android 15+ 16 KB page devices and emulators 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 --- .gitea/workflows/release.yml | 5 ++ apps/mobile/app.config.ts | 2 + apps/mobile/plugins/withArm64Only.js | 18 ++++ apps/web/Dockerfile | 2 + docs/mobile.md | 12 +++ package.json | 2 +- ...pify__react-native-skia@2.0.0-next.4.patch | 15 ++++ pnpm-lock.yaml | 7 +- pnpm-workspace.yaml | 3 + scripts/check-apk.mjs | 83 +++++++++++++++++++ scripts/check-apk.test.mjs | 71 ++++++++++++++++ 11 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/plugins/withArm64Only.js create mode 100644 patches/@shopify__react-native-skia@2.0.0-next.4.patch create mode 100644 scripts/check-apk.mjs create mode 100644 scripts/check-apk.test.mjs diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 132702a..bd2c9c1 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -143,6 +143,11 @@ jobs: fi ./gradlew --stop + # Только arm64-v8a, все нужные .so на месте и выровнены по 16 КБ — иначе APK падает при запуске + # на части устройств (Android 15+ с 16 КБ страницами, эмуляторы x86). + - name: Проверка APK (ABI, 16 КБ страницы) + run: node scripts/check-apk.mjs apps/mobile/android/app/build/outputs/apk/release/app-release.apk + - name: Диагностика сбоя (диск, память, OOM) if: failure() run: | diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index c4ec223..83b96ab 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -31,6 +31,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ // Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка // Android по умолчанию запрещает незашифрованный HTTP. ['expo-build-properties', { android: { usesCleartextTraffic: true } }], + // APK только под arm64-v8a — та же ABI, под которую собираются нативные модули (plugins/withArm64Only.js). + './plugins/withArm64Only', ], extra: { versionCode }, experiments: { typedRoutes: false }, diff --git a/apps/mobile/plugins/withArm64Only.js b/apps/mobile/plugins/withArm64Only.js new file mode 100644 index 0000000..2003840 --- /dev/null +++ b/apps/mobile/plugins/withArm64Only.js @@ -0,0 +1,18 @@ +// Только arm64-v8a в APK. Наши нативные модули (Expo, Skia, Reanimated) собираются под одну ABI +// (-PreactNativeArchitectures=arm64-v8a в release.yml), а готовые библиотеки React Native приносят +// каталоги всех ABI. Без фильтра x86/x86_64-эмулятор и 32-битные устройства выбирают неполный каталог +// и падают при старте: «couldn't find DSO to load: libexpo-modules-core.so». +const { withAppBuildGradle } = require('expo/config-plugins'); + +const MARKER = '// pipu: arm64-v8a only'; + +module.exports = function withArm64Only(config) { + return withAppBuildGradle(config, (cfg) => { + if (cfg.modResults.contents.includes(MARKER)) return cfg; + cfg.modResults.contents = cfg.modResults.contents.replace( + /defaultConfig\s*\{/, + (m) => `${m}\n ${MARKER}\n ndk { abiFilters "arm64-v8a" }`, + ); + return cfg; + }); +}; diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index db17c1d..7185a73 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -8,6 +8,8 @@ WORKDIR /repo # Сначала только манифесты workspace: слой с установкой зависимостей пересобирается лишь при их изменении, # а не при любой правке кода. Новый пакет в apps/ или packages/ — добавить его package.json сюда. COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +# pnpm-патчи зависимостей (lockfile ссылается на них, без них --frozen-lockfile падает) +COPY patches/ patches/ COPY apps/web/package.json apps/web/ COPY apps/mobile/package.json apps/mobile/ COPY packages/api-client/package.json packages/api-client/ diff --git a/docs/mobile.md b/docs/mobile.md index 4a4f7f2..6c7b2c8 100644 --- a/docs/mobile.md +++ b/docs/mobile.md @@ -16,6 +16,18 @@ | i18n | i18next, словари из `packages/i18n` | | Минимальная версия | Android 8.0 (API 26), target — актуальный API | +## Сборка APK: ABI и 16 КБ страницы + +- APK только под `arm64-v8a` (`plugins/withArm64Only.js`): нативные модули собираются под одну ABI, а без фильтра + в APK попадали неполные каталоги `x86`, `x86_64`, `armeabi-v7a` из библиотек React Native — эмулятор x86 и + 32-битные устройства выбирали их и падали при старте (`couldn't find DSO to load: libexpo-modules-core.so`). + Эмулятор для проверки — образ arm64 или настоящее устройство. +- Все `.so` выровнены по 16 КБ: устройства с Android 15+ и 16 КБ страницами памяти не загружают библиотеки + с выравниванием 4 КБ, приложение закрывается сразу после запуска. Skia 2.0.0-next.4 собиралась без + `ANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES` — флаг добавлен pnpm-патчем (`patches/`). +- `scripts/check-apk.mjs` в релизе проверяет ABI, наличие ключевых библиотек и выравнивание; без этого релиз + не публикуется. + ## Экраны Макет — `Pipu Android.dc.html`, сводка решений — [design.md](design.md#android). diff --git a/package.json b/package.json index 1b373b2..732d909 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "generate": "turbo run generate", "i18n:check": "pnpm --filter @pipu/i18n check", "version:info": "node scripts/version.mjs", - "test:scripts": "node --test scripts/version.test.mjs scripts/release.test.mjs" + "test:scripts": "node --test scripts/version.test.mjs scripts/release.test.mjs scripts/check-apk.test.mjs" }, "devDependencies": { "turbo": "2.5.4", diff --git a/patches/@shopify__react-native-skia@2.0.0-next.4.patch b/patches/@shopify__react-native-skia@2.0.0-next.4.patch new file mode 100644 index 0000000..f026688 --- /dev/null +++ b/patches/@shopify__react-native-skia@2.0.0-next.4.patch @@ -0,0 +1,15 @@ +diff --git a/android/build.gradle b/android/build.gradle +index c5431ee7db4fccb4a67fc790ef95db00b5b545dc..bc5feca88bf56c0bf5ac5c4b03f8ab57b693eef5 100644 +--- a/android/build.gradle ++++ b/android/build.gradle +@@ -156,7 +156,9 @@ android { + arguments '-DANDROID_STL=c++_shared', + "-DREACT_NATIVE_VERSION=${REACT_NATIVE_VERSION}", + "-DNODE_MODULES_DIR=${nodeModules}", +- "-DPREBUILT_DIR=${prebuiltDir}" ++ "-DPREBUILT_DIR=${prebuiltDir}", ++ // 16 КБ страницы памяти (Android 15+): иначе librnskia.so не загружается на таких устройствах ++ "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON" + + } + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22bbe78..9b9e26c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + '@shopify/react-native-skia@2.0.0-next.4': ae341532dba11d4a93999214787e561932c027da2a86640d9f1bcad8ba5910b7 + importers: .: @@ -37,7 +40,7 @@ importers: version: 11.4.1(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)) '@shopify/react-native-skia': specifier: 2.0.0-next.4 - version: 2.0.0-next.4(react-native-reanimated@3.17.5(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0) + version: 2.0.0-next.4(patch_hash=ae341532dba11d4a93999214787e561932c027da2a86640d9f1bcad8ba5910b7)(react-native-reanimated@3.17.5(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0) '@tanstack/react-query': specifier: ^5.104.4 version: 5.104.4(@types/react@19.0.14)(react@19.0.0) @@ -5396,7 +5399,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.64.10': optional: true - '@shopify/react-native-skia@2.0.0-next.4(react-native-reanimated@3.17.5(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)': + '@shopify/react-native-skia@2.0.0-next.4(patch_hash=ae341532dba11d4a93999214787e561932c027da2a86640d9f1bcad8ba5910b7)(react-native-reanimated@3.17.5(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)': dependencies: canvaskit-wasm: 0.40.0 react: 19.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4a29478..ffdb85c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,9 @@ packages: allowBuilds: esbuild: true +patchedDependencies: + '@shopify/react-native-skia@2.0.0-next.4': patches/@shopify__react-native-skia@2.0.0-next.4.patch + # Изолированная раскладка node_modules (pnpm 11). Expo вычисляет react-native.config.js пакета expo # по пути через симлинк apps/mobile/node_modules/expo, и оттуда require('expo-modules-autolinking') # находится только в корневом node_modules. Без этого autolinking молча генерирует неверный импорт diff --git a/scripts/check-apk.mjs b/scripts/check-apk.mjs new file mode 100644 index 0000000..314b0cc --- /dev/null +++ b/scripts/check-apk.mjs @@ -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 '); + 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 КБ`); +} diff --git a/scripts/check-apk.test.mjs b/scripts/check-apk.test.mjs new file mode 100644 index 0000000..d8a52db --- /dev/null +++ b/scripts/check-apk.test.mjs @@ -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/); +});