Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,30 @@ jobs:
ANDROID_NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
run: cargo ndk -t arm64-v8a -o ../android/app/src/main/jniLibs build --release --lib

# cpal pulls in oboe (C++), so libcapsuleos_native.so carries a
# DT_NEEDED on libc++_shared.so — but cargo-ndk does not copy that
# runtime from the NDK into jniLibs. Without it dlopen fails and the
# app dies instantly on launch (v2.12.3 shipped exactly that).
- name: Bundle libc++_shared.so (oboe/cpal runtime)
env:
ANDROID_NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
run: |
set -e
SRC="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/aarch64-linux-android/libc++_shared.so"
DST="android/app/src/main/jniLibs/arm64-v8a/libc++_shared.so"
test -f "$SRC" || { echo "libc++_shared.so not found in the NDK sysroot"; exit 1; }
cp -f "$SRC" "$DST"
ls -la android/app/src/main/jniLibs/arm64-v8a/

- name: Assemble debug APK
working-directory: android
run: gradle assembleDebug --no-daemon
# Gate: every DT_NEEDED of every packaged .so must be a system lib or
# be packaged too (plus 16 KB alignment and the NativeActivity export).
# This is what catches a repeat of the v2.12.3 launch crash.
- name: Verify APK native dependencies
run: python3 scripts/check_apk_native_deps.py android/app/build/outputs/apk/debug/*.apk


- uses: actions/upload-artifact@v4
with:
Expand Down
21 changes: 21 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,21 @@ jobs:
ANDROID_NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
run: cargo ndk -t arm64-v8a -o ../android/app/src/main/jniLibs build --release --lib

# cpal pulls in oboe (C++), so libcapsuleos_native.so carries a
# DT_NEEDED on libc++_shared.so — but cargo-ndk does not copy that
# runtime from the NDK into jniLibs. Without it dlopen fails and the
# app dies instantly on launch (v2.12.3 shipped exactly that).
- name: Bundle libc++_shared.so (oboe/cpal runtime)
env:
ANDROID_NDK_HOME: ${{ steps.ndk.outputs.ndk-path }}
run: |
set -e
SRC="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/aarch64-linux-android/libc++_shared.so"
DST="android/app/src/main/jniLibs/arm64-v8a/libc++_shared.so"
test -f "$SRC" || { echo "libc++_shared.so not found in the NDK sysroot"; exit 1; }
cp -f "$SRC" "$DST"
ls -la android/app/src/main/jniLibs/arm64-v8a/

- name: Assemble signed release APK
working-directory: android
env:
Expand All @@ -590,6 +605,12 @@ jobs:
ANDROID_KEY_ALIAS: ${{ env.KS_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ env.KS_PASS }}
run: gradle assembleRelease --no-daemon
# Gate: every DT_NEEDED of every packaged .so must be a system lib or
# be packaged too (plus 16 KB alignment and the NativeActivity export).
# This is what catches a repeat of the v2.12.3 launch crash.
- name: Verify APK native dependencies
run: python3 scripts/check_apk_native_deps.py android/app/build/outputs/apk/release/*.apk


- name: Upload APK to the release
env:
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,24 @@
All notable changes to CapsuleCraft are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versions follow [SemVer](https://semver.org/).

## [2.12.4] — 2026-11-08 — Android actually launches

### Fixed
- **The Android app crashed instantly on launch — on every device.** The
packaged `libcapsuleos_native.so` carries a `DT_NEEDED` on
`libc++_shared.so` (cpal's oboe audio backend is C++), but cargo-ndk does
not copy that runtime out of the NDK and the APK shipped without it: the
loader failed before a single line of Rust ran. Both CI workflows now
bundle `libc++_shared.so` from the NDK sysroot into `jniLibs` and a new
gate (`scripts/check_apk_native_deps.py`, pure python, no SDK needed)
verifies the built APK: every `DT_NEEDED` of every packaged `.so` must be
a system library or be packaged too, PT_LOAD alignment must be 16384
(16 KB-page devices), and `ANativeActivity_onCreate` must be exported.
Diagnosed by auditing the released v2.12.3 APK's ELF tables; the gate
fails on that APK and passes on the fixed layout.
- `versionCode` was pinned at 1 since the first APK, so new releases could
not install over old ones ("App not installed"). It is now bumped per
release (2 for this one).
## [2.12.3] — 2026-11-08 — Search that finishes the thought

A small QoL wave on top of the interactive-graph merge (PR #69). Tests
Expand Down
4 changes: 2 additions & 2 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ android {
applicationId = "com.capsulecraft"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "2.12.3"
versionCode = 2
versionName = "2.12.4"
ndk {
// The Rust lib is built for arm64 by CI (cargo ndk); x86_64 can
// be added for emulator runs.
Expand Down
12 changes: 11 additions & 1 deletion docs/android.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,22 @@

## Что уже есть

- `libc++_shared.so` пакуется в APK шагом «Bundle libc++_shared.so» в CI:
cpal под Android тянет oboe (C++), поэтому `libcapsuleos_native.so` несёт
`DT_NEEDED libc++_shared.so`, а cargo-ndk эту рантайм-библиотеку из NDK не
копирует. Пропажа = мгновенный вылет на запуске (так ломались все сборки до
v2.12.4). Гейт `scripts/check_apk_native_deps.py` прогоняется в CI по
готовому APK: каждая зависимость каждого `.so` должна быть системной либо
упакованной, выравнивание — 16 КБ, экспорт `ANativeActivity_onCreate` — на
месте.

- `native/src/main.rs::android_main` — точка входа: логирование через
`android_logger`, внутренняя папка данных приложения передаётся в
`vault::set_android_data_dir`, хранилище живёт в
`<internal>/capsulecraft/vault`.
- `native/Cargo.toml` — target-зависимости `aarch64`: eframe с фичей
`android-game-activity`, `android-activity 0.6` (та же мажорная версия,
`android-native-activity` (бэкенд обязан совпадать с `NativeActivity` из
манифеста; с v2.12.0), `android-activity 0.6` (та же мажорная версия,
что у winit, чтобы тип `AndroidApp` совпал), `android_logger`; тот же
`src/main.rs` объявлен ещё и целью `[lib]` с `crate-type = ["cdylib"]` —
NativeActivity грузит приложение как `libcapsuleos_native.so`, а не как
Expand Down
48 changes: 48 additions & 0 deletions docs/releases/v2.12.4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# v2.12.4 — Android запускается

Дата: 2026-11-08. Ветка: `fix/android-libcxx-launch`. Чинит: вылет Android-приложения
при запуске (все версии до этой включительно).

## Диагноз

Статический аудит ELF-таблиц релизного APK v2.12.3
(`scripts/check_apk_native_deps.py`): у `lib/arm64-v8a/libcapsuleos_native.so`
среди `DT_NEEDED` есть `libc++_shared.so`, но в APK упакованы только три
нативные библиотеки — самой `libc++_shared.so` нет. Loader на устройстве
резолвит зависимости `dlopen`'a eagerly → библиотека не находится → процесс
умирает до первого байта Rust-кода. Это случилось на любом устройстве.

Откуда зависимость: cpal под Android использует oboe — C++-библиотеку, она
линкуется с рантаймом `libc++_shared.so`. cargo-ndk собирает `.so`, но эту
рантайм-библиотеку из NDK в `jniLibs` не копирует (известная особенность —
он рассчитывает, что это делает сборка gradle/NDK-проекта).

Что при этом было в порядке: выравнивание `PT_LOAD` — 16384 (16 КБ-устройства
не проблема), `ANativeActivity_onCreate` и `android_main` экспортированы,
бэкенд `android-native-activity` соответствует `NativeActivity` из манифеста
(это чинили в v2.12.0 — тогда вылет был по другой причине; теперь открылась
следующая).

## Чин

- `android.yml` и `release.yml`: после `cargo ndk` новый шаг копирует
`libc++_shared.so` из sysroot NDK
(`toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib/aarch64-linux-android/`)
в `android/app/src/main/jniLibs/arm64-v8a/`.
- Новый гейт `scripts/check_apk_native_deps.py` (чистый python, без SDK)
прогоняется по собранному APK в обоих воркфлоу: каждая `DT_NEEDED`
зависимость каждого упакованного `.so` должна быть системной или
упакованной; `PT_LOAD`-выравнивание ≥ 16384; экспорт
`ANativeActivity_onCreate` на месте. На APK v2.12.3 гейт падает, на
исправленном — проходит (проверено локально).
- `versionCode` поднят с навсегда застрявшего `1` до `2` — без этого новая
сборка не ставилась поверх старой («Приложение не установлено»).
- `docs/android.md`: актуальный бэкенд (`android-native-activity`) и раздел
про `libc++_shared.so` + гейт.

## Проверка

- Локально: чекер на релизном APK v2.12.3 → `BROKEN` (exit 1, FAIL по
`libc++_shared.so`); на эмуляции исправленного APK → `LAUNCH-READY`.
- CI: android.yml собирает debug APK и гоняет гейт; релизная сборка по тегу
v2.12.4 прогоняет гейт по подписанному APK до загрузки в релиз.
2 changes: 1 addition & 1 deletion native/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion native/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "capsuleos-native"
version = "2.12.3"
version = "2.12.4"
edition = "2021"
description = "CapsuleCraft native egui prototype (same .eml vault, no webview)"
license = "MIT"
Expand Down
151 changes: 151 additions & 0 deletions scripts/check_apk_native_deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Launch-readiness gate for the Android APK (pure python, no SDK needed).

For every packaged native library (lib/arm64-v8a/*.so) the script checks:

1. every DT_NEEDED entry is either an Android system library (present on
the device) or is itself packaged in the APK — a missing one kills the
app at dlopen time with an instant "app has stopped" (v2.12.3 shipped
without libc++_shared.so, which cpal/oboe pull in, and crashed on
launch on every device);
2. PT_LOAD alignment is 16384 bytes — Android 15+ 16 KB-page devices
refuse to load 4 KB-aligned libraries;
3. the NativeActivity entry point (ANativeActivity_onCreate) is exported
by the main library.

Usage: python3 scripts/check_apk_native_deps.py <app.apk> [<app2.apk> ...]
Exit codes: 0 = all APKs launch-ready, 1 = a problem was found.
"""
import struct
import sys
import zipfile

# Libraries every Android device provides out of the box (NDK/bionic +
# framework). Anything else must be packaged in the APK.
SYSTEM_LIBS = {
"libc.so", "libdl.so", "libm.so", "liblog.so", "libandroid.so",
"libOpenSLES.so", "libaaudio.so", "libEGL.so", "libGLESv2.so",
"libGLESv3.so", "libvulkan.so", "libz.so", "libnativewindow.so",
"libsync.so", "libstdc++.so", "libmediandk.so", "libcamera2ndk.so",
"libjnigraphics.so", "libwilhelm.so", "libunwind.so",
}

MIN_PAGE_ALIGN = 16384


def dyn_needed(data: bytes):
"""DT_NEEDED entries of an ELF64 shared object."""
e_phoff, = struct.unpack_from("<Q", data, 32)
e_phentsize, e_phnum = struct.unpack_from("<HH", data, 54)
dyn = None
max_align = 0
for i in range(e_phnum):
off = e_phoff + i * e_phentsize
(p_type, _flags, p_offset, _vaddr, _paddr, p_filesz,
_memsz, p_align) = struct.unpack_from("<IIQQQQQQ", data, off)
if p_type == 1: # PT_LOAD
max_align = max(max_align, p_align)
elif p_type == 2: # PT_DYNAMIC
dyn = (p_offset, p_filesz)
if dyn is None:
return [], max_align
off, size = dyn
strtab = None
tags = []
for i in range(size // 16):
d_tag, d_val = struct.unpack_from("<qQ", data, off + i * 16)
if d_tag == 0:
break
tags.append((d_tag, d_val))
if d_tag == 5: # DT_STRTAB
strtab = d_val
needed = []
for d_tag, d_val in tags:
if d_tag == 1 and strtab is not None: # DT_NEEDED
end = data.index(b"\0", strtab + d_val)
needed.append(data[strtab + d_val:end].decode())
return needed, max_align


def defined_symbols(data: bytes):
"""Symbols defined (not imported) by the ELF, via .dynsym/.dynstr."""
e_shoff, = struct.unpack_from("<Q", data, 40)
e_shentsize, e_shnum, e_shstrndx = struct.unpack_from("<HHH", data, 58)
sections = []
for i in range(e_shnum):
off = e_shoff + i * e_shentsize
(name, _typ, _flags, _addr, offset, size, link,
_info, _align, entsize) = struct.unpack_from("<IIQQQQIIQQ", data, off)
sections.append(dict(name=name, offset=offset, size=size, link=link,
entsize=entsize))
shstr = sections[e_shstrndx]
name_at = shstr["offset"]

def secname(n):
end = data.index(b"\0", name_at + n)
return data[name_at + n:end].decode()

syms = set()
for s in sections:
if secname(s["name"]) != ".dynsym":
continue
dynstr = sections[s["link"]]
for i in range(s["size"] // s["entsize"]):
off = s["offset"] + i * s["entsize"]
st_name, _info, _other, st_shndx, _value, _size = struct.unpack_from(
"<IBBHQQ", data, off)
if st_shndx == 0 or st_name == 0:
continue
end = data.index(b"\0", dynstr["offset"] + st_name)
syms.add(data[dynstr["offset"] + st_name:end].decode())
return syms


def check_apk(path: str) -> bool:
ok = True
with zipfile.ZipFile(path) as z:
libs = [n for n in z.namelist() if n.startswith("lib/armeabi")
or n.startswith("lib/arm64") or n.startswith("lib/x86")]
packaged = {n.rsplit("/", 1)[-1] for n in libs}
print(f"== {path}: {len(libs)} native libs")
main_lib = None
for name in libs:
data = z.read(name)
short = name.rsplit("/", 1)[-1]
needed, align = dyn_needed(data)
if align < MIN_PAGE_ALIGN:
print(f" FAIL {short}: PT_LOAD alignment {align} < {MIN_PAGE_ALIGN} "
"(16 KB-page devices refuse to load it)")
ok = False
for dep in needed:
if dep not in SYSTEM_LIBS and dep not in packaged:
print(f" FAIL {short}: needs {dep} — not in the APK and not a "
"system library (dlopen fails at launch)")
ok = False
print(f" {short}: align={align}, deps={', '.join(needed) or '—'}")
if defined_symbols_check(data, "ANativeActivity_onCreate"):
main_lib = short
if main_lib is None:
print(" FAIL: no packaged library exports ANativeActivity_onCreate "
"(NativeActivity has nothing to call — instant crash)")
ok = False
else:
print(f" ANativeActivity_onCreate: exported by {main_lib}")
return ok


def defined_symbols_check(data: bytes, symbol: str) -> bool:
return symbol in defined_symbols(data)


def main(argv):
if len(argv) < 2:
print(__doc__)
return 2
ok = all(check_apk(p) for p in argv[1:])
print("LAUNCH-READY" if ok else "BROKEN — see FAIL lines above")
return 0 if ok else 1


if __name__ == "__main__":
sys.exit(main(sys.argv))
Loading