Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73e642c1ec |
No files matched your search
@@ -143,6 +143,11 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
./gradlew --stop
|
./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)
|
- name: Диагностика сбоя (диск, память, OOM)
|
||||||
if: failure()
|
if: failure()
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||||||
// Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка
|
// Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка
|
||||||
// Android по умолчанию запрещает незашифрованный HTTP.
|
// Android по умолчанию запрещает незашифрованный HTTP.
|
||||||
['expo-build-properties', { android: { usesCleartextTraffic: true } }],
|
['expo-build-properties', { android: { usesCleartextTraffic: true } }],
|
||||||
|
// APK только под arm64-v8a — та же ABI, под которую собираются нативные модули (plugins/withArm64Only.js).
|
||||||
|
'./plugins/withArm64Only',
|
||||||
],
|
],
|
||||||
extra: { versionCode },
|
extra: { versionCode },
|
||||||
experiments: { typedRoutes: false },
|
experiments: { typedRoutes: false },
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -8,6 +8,8 @@ WORKDIR /repo
|
|||||||
# Сначала только манифесты workspace: слой с установкой зависимостей пересобирается лишь при их изменении,
|
# Сначала только манифесты workspace: слой с установкой зависимостей пересобирается лишь при их изменении,
|
||||||
# а не при любой правке кода. Новый пакет в apps/ или packages/ — добавить его package.json сюда.
|
# а не при любой правке кода. Новый пакет в apps/ или packages/ — добавить его package.json сюда.
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
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/web/package.json apps/web/
|
||||||
COPY apps/mobile/package.json apps/mobile/
|
COPY apps/mobile/package.json apps/mobile/
|
||||||
COPY packages/api-client/package.json packages/api-client/
|
COPY packages/api-client/package.json packages/api-client/
|
||||||
|
|||||||
@@ -16,6 +16,18 @@
|
|||||||
| i18n | i18next, словари из `packages/i18n` |
|
| i18n | i18next, словари из `packages/i18n` |
|
||||||
| Минимальная версия | Android 8.0 (API 26), target — актуальный API |
|
| Минимальная версия | 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).
|
Макет — `Pipu Android.dc.html`, сводка решений — [design.md](design.md#android).
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
"generate": "turbo run generate",
|
"generate": "turbo run generate",
|
||||||
"i18n:check": "pnpm --filter @pipu/i18n check",
|
"i18n:check": "pnpm --filter @pipu/i18n check",
|
||||||
"version:info": "node scripts/version.mjs",
|
"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": {
|
"devDependencies": {
|
||||||
"turbo": "2.5.4",
|
"turbo": "2.5.4",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+5
-2
@@ -4,6 +4,9 @@ settings:
|
|||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
patchedDependencies:
|
||||||
|
'@shopify/react-native-skia@2.0.0-next.4': ae341532dba11d4a93999214787e561932c027da2a86640d9f1bcad8ba5910b7
|
||||||
|
|
||||||
importers:
|
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))
|
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':
|
'@shopify/react-native-skia':
|
||||||
specifier: 2.0.0-next.4
|
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':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.104.4
|
specifier: ^5.104.4
|
||||||
version: 5.104.4(@types/react@19.0.14)(react@19.0.0)
|
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':
|
'@rollup/rollup-win32-x64-msvc@4.64.10':
|
||||||
optional: true
|
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:
|
dependencies:
|
||||||
canvaskit-wasm: 0.40.0
|
canvaskit-wasm: 0.40.0
|
||||||
react: 19.0.0
|
react: 19.0.0
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ packages:
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
esbuild: true
|
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
|
# Изолированная раскладка node_modules (pnpm 11). Expo вычисляет react-native.config.js пакета expo
|
||||||
# по пути через симлинк apps/mobile/node_modules/expo, и оттуда require('expo-modules-autolinking')
|
# по пути через симлинк apps/mobile/node_modules/expo, и оттуда require('expo-modules-autolinking')
|
||||||
# находится только в корневом node_modules. Без этого autolinking молча генерирует неверный импорт
|
# находится только в корневом node_modules. Без этого autolinking молча генерирует неверный импорт
|
||||||
|
|||||||
@@ -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