feat(android): Phase 4 — Rust core to Android .so via JNI
Implement the shared-Rust-core leg of the Android Stage-1 prototype
(docs/superpowers/specs/2026-06-27-android-stage1-prototype-plan.md).
android/rust/catacomb_core is a cdylib that reuses the pure desktop
modules (vtt, error_class, platform) verbatim via #[path] includes — no
logic fork — and exposes them to Kotlin over JNI as String-in/String-out
entry points:
RustCore.vttParse(vtt) -> JSON [{start,text},...]
RustCore.classifyError(log) -> JSON {class,label,hint}
RustCore.platformFromUrl(url) -> JSON {dir_name,display_name,icon}
RustCore.platformDirName(url) -> folder name
Built with panic=abort + per-entry catch_unwind so a Rust panic can never
unwind into the JVM. build.sh locates the NDK portably and cross-compiles
to arm64-v8a + x86_64, depositing the .so into app/src/main/jniLibs/.
Verified: cargo test (38 pass), llvm-readelf confirms all 4 JNI symbols
exported in the arm64 .so, and an end-to-end host-JVM round-trip
(System.load + call each native method) returns correct JSON.
Phases 2/3/5 (WebView-BotGuard anti-bot) remain device-only and untouched.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
f3e6d3b5b7
commit
9a613239f2
7 changed files with 796 additions and 0 deletions
14
android/.gitignore
vendored
Normal file
14
android/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# Rust build artifacts
|
||||||
|
rust/catacomb_core/target/
|
||||||
|
|
||||||
|
# Compiled native libs land here via rust/catacomb_core/build.sh — rebuild
|
||||||
|
# rather than commit binaries.
|
||||||
|
app/src/main/jniLibs/
|
||||||
|
|
||||||
|
# Gradle / Android build outputs (Phase 1, not yet scaffolded here)
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
app/build/
|
||||||
|
local.properties
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
78
android/README.md
Normal file
78
android/README.md
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
# Catacomb Android (Stage-1 prototype)
|
||||||
|
|
||||||
|
This directory holds the Android Stage-1 feasibility prototype described in
|
||||||
|
[`docs/superpowers/specs/2026-06-27-android-stage1-prototype-plan.md`](../docs/superpowers/specs/2026-06-27-android-stage1-prototype-plan.md).
|
||||||
|
|
||||||
|
## Status by phase
|
||||||
|
|
||||||
|
| Phase | What | Status |
|
||||||
|
|-------|------|--------|
|
||||||
|
| 1 | App shell + `yt-dlp-android` integration | Not started (needs Compose deps + the `yt-dlp-android` release) |
|
||||||
|
| 2 | WebView-BotGuard POT generation | Blocked here — needs a real device + genuine Google session |
|
||||||
|
| 3 | Token passing + anti-bot test | Blocked here — device-only |
|
||||||
|
| 4 | **Rust core → Android `.so` via JNI** | ✅ **Implemented & verified** |
|
||||||
|
| 5 | On-device integration test → Go/No-Go | Device-only |
|
||||||
|
|
||||||
|
Phases 2/3/5 require a physical Android device with a logged-in Google session
|
||||||
|
(the WebView anti-bot test), so they can't be executed in a headless CI/dev
|
||||||
|
environment. Phase 4 — the "shared Rust core" leg the feasibility research
|
||||||
|
marked **PROVEN** — is fully implemented and verified here.
|
||||||
|
|
||||||
|
## Phase 4: `rust/catacomb_core`
|
||||||
|
|
||||||
|
A `cdylib` that reuses Catacomb's **pure** modules verbatim (`vtt`,
|
||||||
|
`error_class`, `platform`) via `#[path]` includes — no logic fork — and exposes
|
||||||
|
them to Kotlin over JNI. See [`rust/catacomb_core/src/lib.rs`](rust/catacomb_core/src/lib.rs).
|
||||||
|
|
||||||
|
Exposed entry points (all String-in / String-out), bound in
|
||||||
|
[`app/src/main/java/com/catacomb/spike/RustCore.kt`](app/src/main/java/com/catacomb/spike/RustCore.kt):
|
||||||
|
|
||||||
|
| Kotlin | Returns |
|
||||||
|
|--------|---------|
|
||||||
|
| `RustCore.vttParse(vtt)` | JSON `[{start, text}, …]` |
|
||||||
|
| `RustCore.classifyError(log)` | JSON `{class, label, hint}` |
|
||||||
|
| `RustCore.platformFromUrl(url)` | JSON `{dir_name, display_name, icon}` |
|
||||||
|
| `RustCore.platformDirName(url)` | plain folder name, e.g. `channels` |
|
||||||
|
|
||||||
|
### Build the native libs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rust/catacomb_core
|
||||||
|
./build.sh # arm64-v8a + x86_64 (release) → app/src/main/jniLibs/<abi>/
|
||||||
|
./build.sh arm64-v8a # single ABI
|
||||||
|
API=24 ./build.sh # override min-API linker (default 24)
|
||||||
|
```
|
||||||
|
|
||||||
|
The script locates the NDK from `$ANDROID_NDK_HOME`, `$ANDROID_NDK_ROOT`, or the
|
||||||
|
newest `ndk/<ver>` under `$ANDROID_HOME` / `$ANDROID_SDK_ROOT` / `~/Android/Sdk`.
|
||||||
|
Only the target linker is configured — every dependency (`serde`, `serde_json`,
|
||||||
|
`jni`) is pure Rust, so there's no C toolchain to set up.
|
||||||
|
|
||||||
|
Outputs (git-ignored — rebuild rather than commit binaries):
|
||||||
|
`app/src/main/jniLibs/{arm64-v8a,x86_64}/libcatacomb_core.so`.
|
||||||
|
|
||||||
|
### Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd rust/catacomb_core
|
||||||
|
|
||||||
|
# 1. Logic reachable through the crate (runs the shared modules' own tests too):
|
||||||
|
cargo test
|
||||||
|
|
||||||
|
# 2. Exported JNI symbols in the shipped (arm64) .so:
|
||||||
|
NDK=~/Android/Sdk/ndk/26.3.11579264
|
||||||
|
$NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-readelf --dyn-syms \
|
||||||
|
target/aarch64-linux-android/release/libcatacomb_core.so | grep Java_com_catacomb
|
||||||
|
# → Java_com_catacomb_spike_RustCore_{vttParse,classifyError,platformFromUrl,platformDirName}
|
||||||
|
```
|
||||||
|
|
||||||
|
An end-to-end JVM round-trip (build a host `.so`, `System.load` it, call each
|
||||||
|
native method) was used during development to confirm the bridge works at
|
||||||
|
runtime, not just that the symbols resolve.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The JNI method names in `RustCore.kt` **must** match the Rust
|
||||||
|
`Java_com_catacomb_spike_RustCore_<name>` exports exactly.
|
||||||
|
- The `.so` is built with `panic = "abort"` and every entry point wraps its body
|
||||||
|
in `catch_unwind`, so a Rust panic can never unwind into the JVM (UB).
|
||||||
51
android/app/src/main/java/com/catacomb/spike/RustCore.kt
Normal file
51
android/app/src/main/java/com/catacomb/spike/RustCore.kt
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
package com.catacomb.spike
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kotlin binding for the Catacomb Rust core (`libcatacomb_core.so`).
|
||||||
|
*
|
||||||
|
* This is the JVM side of the Phase-4 JNI bridge proven in
|
||||||
|
* `android/rust/catacomb_core`. Each `external fun` resolves to a
|
||||||
|
* `#[no_mangle] pub extern "system" fn Java_com_catacomb_spike_RustCore_<name>`
|
||||||
|
* symbol in the shared library, so the method names here **must** match the
|
||||||
|
* Rust exports exactly (verified via `llvm-readelf --dyn-syms`).
|
||||||
|
*
|
||||||
|
* The `.so` must be on the app's `jniLibs` path per ABI, e.g.
|
||||||
|
* `app/src/main/jniLibs/arm64-v8a/libcatacomb_core.so`
|
||||||
|
* (produced by `android/rust/catacomb_core/build.sh`).
|
||||||
|
*
|
||||||
|
* All calls cross into Rust, which reuses the *same* modules the desktop/web
|
||||||
|
* build uses (`vtt`, `error_class`, `platform`) — no logic fork.
|
||||||
|
*/
|
||||||
|
object RustCore {
|
||||||
|
init {
|
||||||
|
// Loads libcatacomb_core.so from the ABI-specific jniLibs folder.
|
||||||
|
System.loadLibrary("catacomb_core")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse WebVTT/SRT subtitle text into a JSON array of cues:
|
||||||
|
* `[{"start": <seconds:Double>, "text": <String>}, …]`.
|
||||||
|
* Returns `"[]"` for empty/unparseable input.
|
||||||
|
*/
|
||||||
|
external fun vttParse(vtt: String): String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a yt-dlp failure log (one log entry per line) into JSON:
|
||||||
|
* `{"class": <kebab-id>, "label": <String>, "hint": <String>}`.
|
||||||
|
* `class` is one of: rate-limited, members-only, geo-blocked, not-found,
|
||||||
|
* codec-missing, disk-full, network-error, bad-cookies, other.
|
||||||
|
*/
|
||||||
|
external fun classifyError(log: String): String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a media URL to its platform descriptor JSON:
|
||||||
|
* `{"dir_name": <String>, "display_name": <String>, "icon": <String>}`.
|
||||||
|
*/
|
||||||
|
external fun platformFromUrl(url: String): String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The on-disk backup-folder name for a URL's platform
|
||||||
|
* (e.g. `"channels"` for YouTube, `"tiktok"`, …, `"other"`).
|
||||||
|
*/
|
||||||
|
external fun platformDirName(url: String): String
|
||||||
|
}
|
||||||
315
android/rust/catacomb_core/Cargo.lock
generated
Normal file
315
android/rust/catacomb_core/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytes"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "catacomb_core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"jni",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cesu8"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "combine"
|
||||||
|
version = "4.6.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni"
|
||||||
|
version = "0.21.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
|
||||||
|
dependencies = [
|
||||||
|
"cesu8",
|
||||||
|
"cfg-if",
|
||||||
|
"combine",
|
||||||
|
"jni-sys 0.3.1",
|
||||||
|
"log",
|
||||||
|
"thiserror",
|
||||||
|
"walkdir",
|
||||||
|
"windows-sys 0.45.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni-sys"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
|
||||||
|
dependencies = [
|
||||||
|
"jni-sys 0.4.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni-sys"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
|
||||||
|
dependencies = [
|
||||||
|
"jni-sys-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni-sys-macros"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "log"
|
||||||
|
version = "0.4.33"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "same-file"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-util",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.150"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.118"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "walkdir"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
|
||||||
|
dependencies = [
|
||||||
|
"same-file",
|
||||||
|
"winapi-util",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-util"
|
||||||
|
version = "0.1.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.45.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm",
|
||||||
|
"windows_aarch64_msvc",
|
||||||
|
"windows_i686_gnu",
|
||||||
|
"windows_i686_msvc",
|
||||||
|
"windows_x86_64_gnu",
|
||||||
|
"windows_x86_64_gnullvm",
|
||||||
|
"windows_x86_64_msvc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
40
android/rust/catacomb_core/Cargo.toml
Normal file
40
android/rust/catacomb_core/Cargo.toml
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Android JNI core for Catacomb (Stage-1 prototype, Phase 4).
|
||||||
|
#
|
||||||
|
# A thin cdylib that reuses Catacomb's *pure* modules (vtt, error_class,
|
||||||
|
# platform) verbatim via `#[path]` includes so the on-device engine stays in
|
||||||
|
# lockstep with the desktop/web build instead of forking the logic. It exposes
|
||||||
|
# a handful of String-in / String-out JNI entry points the Kotlin layer calls.
|
||||||
|
#
|
||||||
|
# The empty `[workspace]` table isolates this crate from the desktop package
|
||||||
|
# that lives at the repository root (which is a plain package, not a
|
||||||
|
# workspace); without it cargo would try to treat the two as one workspace.
|
||||||
|
[workspace]
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "catacomb_core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "AGPL-3.0-only"
|
||||||
|
description = "Android JNI bridge exposing Catacomb's pure Rust modules on-device."
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
# cdylib → a .so loadable by the JVM via System.loadLibrary("catacomb_core").
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# Same major as the desktop crate so the shared modules' derives resolve
|
||||||
|
# identically.
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
# JNI bindings. 0.21 is the current stable line and links against the JVM's
|
||||||
|
# built-in libjvm at runtime (no extra native dep on device).
|
||||||
|
jni = "0.21"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
# Small, fast .so: LTO + panic=abort (a Rust panic must never unwind across
|
||||||
|
# the FFI boundary into the JVM — that's UB; abort is the safe choice, and we
|
||||||
|
# also catch panics at each entry point as defense in depth).
|
||||||
|
opt-level = "z"
|
||||||
|
lto = true
|
||||||
|
panic = "abort"
|
||||||
|
strip = true
|
||||||
77
android/rust/catacomb_core/build.sh
Executable file
77
android/rust/catacomb_core/build.sh
Executable file
|
|
@ -0,0 +1,77 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Cross-compile the Catacomb Android JNI core to Android .so files.
|
||||||
|
#
|
||||||
|
# Portable: locates the NDK from (in order) $ANDROID_NDK_HOME, $ANDROID_NDK_ROOT,
|
||||||
|
# or the newest ndk/<ver> under $ANDROID_HOME / $ANDROID_SDK_ROOT / ~/Android/Sdk.
|
||||||
|
# Only the target linker is needed — every dependency (serde, serde_json, jni)
|
||||||
|
# is pure Rust, so there's no C compiler / archiver to configure.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./build.sh # build arm64-v8a + x86_64 (release)
|
||||||
|
# ./build.sh arm64-v8a # single ABI
|
||||||
|
# API=24 ./build.sh # override the min-API linker (default 24)
|
||||||
|
#
|
||||||
|
# Output .so files land in target/<rust-triple>/release/libcatacomb_core.so.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
API="${API:-24}"
|
||||||
|
|
||||||
|
# ── Locate the NDK ──────────────────────────────────────────────────────────
|
||||||
|
find_ndk() {
|
||||||
|
if [[ -n "${ANDROID_NDK_HOME:-}" && -d "$ANDROID_NDK_HOME" ]]; then
|
||||||
|
echo "$ANDROID_NDK_HOME"; return
|
||||||
|
fi
|
||||||
|
if [[ -n "${ANDROID_NDK_ROOT:-}" && -d "$ANDROID_NDK_ROOT" ]]; then
|
||||||
|
echo "$ANDROID_NDK_ROOT"; return
|
||||||
|
fi
|
||||||
|
for sdk in "${ANDROID_HOME:-}" "${ANDROID_SDK_ROOT:-}" "$HOME/Android/Sdk"; do
|
||||||
|
[[ -n "$sdk" && -d "$sdk/ndk" ]] || continue
|
||||||
|
# newest installed NDK version
|
||||||
|
local ver
|
||||||
|
ver="$(ls -1 "$sdk/ndk" | sort -V | tail -1)"
|
||||||
|
[[ -n "$ver" ]] && { echo "$sdk/ndk/$ver"; return; }
|
||||||
|
done
|
||||||
|
echo "ERROR: could not locate an Android NDK. Set ANDROID_NDK_HOME." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
NDK="$(find_ndk)"
|
||||||
|
HOST_TAG="linux-x86_64"
|
||||||
|
[[ "$(uname -s)" == "Darwin" ]] && HOST_TAG="darwin-x86_64"
|
||||||
|
TOOLS="$NDK/toolchains/llvm/prebuilt/$HOST_TAG/bin"
|
||||||
|
echo "Using NDK: $NDK (API $API)"
|
||||||
|
|
||||||
|
# ── ABI → rust triple + clang wrapper ────────────────────────────────────────
|
||||||
|
declare -A TRIPLE=( [arm64-v8a]=aarch64-linux-android [x86_64]=x86_64-linux-android )
|
||||||
|
|
||||||
|
build_one() {
|
||||||
|
local abi="$1" triple="${TRIPLE[$1]}"
|
||||||
|
local linker="$TOOLS/${triple}${API}-clang"
|
||||||
|
if [[ ! -x "$linker" ]]; then
|
||||||
|
echo "ERROR: linker not found: $linker" >&2; exit 1
|
||||||
|
fi
|
||||||
|
echo "── Building $abi ($triple) ─────────────────────────────"
|
||||||
|
# cargo derives the env var name from the uppercased triple.
|
||||||
|
local var="CARGO_TARGET_$(echo "$triple" | tr 'a-z-' 'A-Z_')_LINKER"
|
||||||
|
env "$var=$linker" cargo build --release --target "$triple"
|
||||||
|
local out="target/$triple/release/libcatacomb_core.so"
|
||||||
|
if [[ ! -f "$out" ]]; then
|
||||||
|
echo " ✗ expected $out was not produced" >&2; exit 1
|
||||||
|
fi
|
||||||
|
# Deposit into the app's ABI-specific jniLibs dir so the Kotlin
|
||||||
|
# `System.loadLibrary("catacomb_core")` finds it at packaging time.
|
||||||
|
local jnidir="../../app/src/main/jniLibs/$abi"
|
||||||
|
mkdir -p "$jnidir"
|
||||||
|
cp "$out" "$jnidir/libcatacomb_core.so"
|
||||||
|
echo " ✓ $out ($(du -h "$out" | cut -f1)) → $jnidir/"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ $# -gt 0 ]]; then
|
||||||
|
build_one "$1"
|
||||||
|
else
|
||||||
|
build_one arm64-v8a
|
||||||
|
build_one x86_64
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Done."
|
||||||
221
android/rust/catacomb_core/src/lib.rs
Normal file
221
android/rust/catacomb_core/src/lib.rs
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
//! Catacomb Android JNI core (Stage-1 prototype, Phase 4).
|
||||||
|
//!
|
||||||
|
//! This crate proves the "shared Rust core" leg of the Android feasibility
|
||||||
|
//! spike: the *same* pure modules the desktop/web binary uses
|
||||||
|
//! (`vtt`, `error_class`, `platform`) cross-compile to an Android `.so` and are
|
||||||
|
//! callable from Kotlin over JNI — no logic fork, no rewrite.
|
||||||
|
//!
|
||||||
|
//! ## What's exposed
|
||||||
|
//!
|
||||||
|
//! Every entry point is String-in / String-out (JNI's easiest, most portable
|
||||||
|
//! shape) and mirrors a desktop function:
|
||||||
|
//!
|
||||||
|
//! | Kotlin (`RustCore`) | Rust module | Purpose |
|
||||||
|
//! |--------------------------|----------------------|-------------------------------------------|
|
||||||
|
//! | `vttParse(String)` | [`vtt::parse`] | VTT/SRT → JSON `[{start,text}, …]` |
|
||||||
|
//! | `classifyError(String)` | [`error_class`] | yt-dlp log → `{class,label,hint}` JSON |
|
||||||
|
//! | `platformFromUrl(String)`| [`platform`] | URL → `{dir_name,display_name,icon}` JSON |
|
||||||
|
//! | `platformDirName(String)`| [`platform`] | URL → backup-folder name (plain string) |
|
||||||
|
//!
|
||||||
|
//! ## Panic safety
|
||||||
|
//!
|
||||||
|
//! A Rust panic unwinding across the FFI boundary into the JVM is undefined
|
||||||
|
//! behaviour. The crate is built with `panic = "abort"`, and every entry point
|
||||||
|
//! additionally wraps its body in [`std::panic::catch_unwind`] so a bug
|
||||||
|
//! degrades to an empty/typed result instead of tearing down the process.
|
||||||
|
|
||||||
|
// ── Shared, unmodified Catacomb modules ─────────────────────────────────────
|
||||||
|
// Included by path (not copied) so this stays in lockstep with the desktop
|
||||||
|
// source. These are pure std + serde and carry no platform-specific code.
|
||||||
|
#[path = "../../../../src/vtt.rs"]
|
||||||
|
pub mod vtt;
|
||||||
|
#[path = "../../../../src/error_class.rs"]
|
||||||
|
pub mod error_class;
|
||||||
|
#[path = "../../../../src/platform.rs"]
|
||||||
|
pub mod platform;
|
||||||
|
|
||||||
|
use jni::objects::{JClass, JString};
|
||||||
|
use jni::sys::jstring;
|
||||||
|
use jni::JNIEnv;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// Serializable view of a parsed subtitle cue (JNI can't hand back the native
|
||||||
|
/// [`vtt::Cue`] directly, so we JSON-encode a flat list of these).
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CueJson {
|
||||||
|
start: f64,
|
||||||
|
text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializable classification result: the kebab-case class id plus its
|
||||||
|
/// human-facing label and hint.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ClassificationJson {
|
||||||
|
class: String,
|
||||||
|
label: String,
|
||||||
|
hint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializable platform descriptor for a URL.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct PlatformJson {
|
||||||
|
dir_name: String,
|
||||||
|
display_name: String,
|
||||||
|
icon: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a `JString` argument into an owned Rust `String`, tolerating a null /
|
||||||
|
/// undecodable value by returning an empty string (never panics).
|
||||||
|
fn jstring_to_string(env: &mut JNIEnv, s: &JString) -> String {
|
||||||
|
if s.is_null() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
env.get_string(s)
|
||||||
|
.map(|js| js.into())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a Java `String` from a Rust `&str`. On the (near-impossible) failure
|
||||||
|
/// to allocate a Java string, returns a null jstring so the JVM sees `null`
|
||||||
|
/// rather than a dangling pointer.
|
||||||
|
fn to_jstring(env: &mut JNIEnv, s: &str) -> jstring {
|
||||||
|
match env.new_string(s) {
|
||||||
|
Ok(js) => js.into_raw(),
|
||||||
|
Err(_) => std::ptr::null_mut(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `f` (which produces a String) under `catch_unwind`, returning a Java
|
||||||
|
/// string of the result or `fallback` if the closure panicked.
|
||||||
|
fn guarded_string<F: FnOnce() -> String + std::panic::UnwindSafe>(
|
||||||
|
env: &mut JNIEnv,
|
||||||
|
fallback: &str,
|
||||||
|
f: F,
|
||||||
|
) -> jstring {
|
||||||
|
match std::panic::catch_unwind(f) {
|
||||||
|
Ok(out) => to_jstring(env, &out),
|
||||||
|
Err(_) => to_jstring(env, fallback),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `RustCore.vttParse(vtt: String): String` — parse WebVTT/SRT text into a JSON
|
||||||
|
/// array of `{start, text}` cues (empty array on any error).
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_com_catacomb_spike_RustCore_vttParse<'local>(
|
||||||
|
mut env: JNIEnv<'local>,
|
||||||
|
_class: JClass<'local>,
|
||||||
|
input: JString<'local>,
|
||||||
|
) -> jstring {
|
||||||
|
let text = jstring_to_string(&mut env, &input);
|
||||||
|
guarded_string(&mut env, "[]", move || {
|
||||||
|
let cues: Vec<CueJson> = vtt::parse(&text)
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| CueJson { start: c.start, text: c.text })
|
||||||
|
.collect();
|
||||||
|
serde_json::to_string(&cues).unwrap_or_else(|_| "[]".to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `RustCore.classifyError(log: String): String` — classify a yt-dlp failure
|
||||||
|
/// log (one entry per line) into `{class, label, hint}` JSON.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_com_catacomb_spike_RustCore_classifyError<'local>(
|
||||||
|
mut env: JNIEnv<'local>,
|
||||||
|
_class: JClass<'local>,
|
||||||
|
input: JString<'local>,
|
||||||
|
) -> jstring {
|
||||||
|
let log = jstring_to_string(&mut env, &input);
|
||||||
|
guarded_string(&mut env, "{}", move || {
|
||||||
|
let class = error_class::classify(log.lines());
|
||||||
|
// The enum serializes to its kebab-case id (e.g. "rate-limited").
|
||||||
|
let class_id = serde_json::to_value(class)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_str().map(str::to_string))
|
||||||
|
.unwrap_or_else(|| "other".to_string());
|
||||||
|
let out = ClassificationJson {
|
||||||
|
class: class_id,
|
||||||
|
label: class.label().to_string(),
|
||||||
|
hint: class.hint().to_string(),
|
||||||
|
};
|
||||||
|
serde_json::to_string(&out).unwrap_or_else(|_| "{}".to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `RustCore.platformFromUrl(url: String): String` — resolve a URL to its
|
||||||
|
/// platform descriptor `{dir_name, display_name, icon}` JSON.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_com_catacomb_spike_RustCore_platformFromUrl<'local>(
|
||||||
|
mut env: JNIEnv<'local>,
|
||||||
|
_class: JClass<'local>,
|
||||||
|
input: JString<'local>,
|
||||||
|
) -> jstring {
|
||||||
|
let url = jstring_to_string(&mut env, &input);
|
||||||
|
guarded_string(&mut env, "{}", move || {
|
||||||
|
let p = platform::Platform::from_url(&url);
|
||||||
|
let out = PlatformJson {
|
||||||
|
dir_name: p.dir_name().to_string(),
|
||||||
|
display_name: p.display_name().to_string(),
|
||||||
|
icon: p.icon().to_string(),
|
||||||
|
};
|
||||||
|
serde_json::to_string(&out).unwrap_or_else(|_| "{}".to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `RustCore.platformDirName(url: String): String` — the backup-folder name for
|
||||||
|
/// a URL's platform (e.g. `"channels"` for YouTube). Plain string, no JSON.
|
||||||
|
#[no_mangle]
|
||||||
|
pub extern "system" fn Java_com_catacomb_spike_RustCore_platformDirName<'local>(
|
||||||
|
mut env: JNIEnv<'local>,
|
||||||
|
_class: JClass<'local>,
|
||||||
|
input: JString<'local>,
|
||||||
|
) -> jstring {
|
||||||
|
let url = jstring_to_string(&mut env, &input);
|
||||||
|
guarded_string(&mut env, "other", move || {
|
||||||
|
platform::Platform::from_url(&url).dir_name().to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Host-side tests ─────────────────────────────────────────────────────────
|
||||||
|
// These exercise the wrapped logic through this crate (not JNI, which needs a
|
||||||
|
// JVM) so a plain `cargo test` on the dev host proves the shared modules are
|
||||||
|
// reachable and produce the JSON shapes the Kotlin layer expects.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vtt_parse_produces_cue_json() {
|
||||||
|
let sample = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000\nHello world\n";
|
||||||
|
let cues: Vec<CueJson> = vtt::parse(sample)
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| CueJson { start: c.start, text: c.text })
|
||||||
|
.collect();
|
||||||
|
let json = serde_json::to_string(&cues).unwrap();
|
||||||
|
assert!(json.contains("\"start\":1.0"), "json: {json}");
|
||||||
|
assert!(json.contains("Hello world"), "json: {json}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_rate_limit_serializes_kebab_case() {
|
||||||
|
let class = error_class::classify(
|
||||||
|
["ERROR: HTTP Error 429: Too Many Requests"].into_iter(),
|
||||||
|
);
|
||||||
|
let id = serde_json::to_value(class).unwrap();
|
||||||
|
assert_eq!(id.as_str(), Some("rate-limited"));
|
||||||
|
assert_eq!(class.label(), "rate-limited");
|
||||||
|
assert!(!class.hint().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn platform_from_url_youtube_dir_is_channels() {
|
||||||
|
let p = platform::Platform::from_url("https://youtu.be/dQw4w9WgXcQ");
|
||||||
|
assert_eq!(p.dir_name(), "channels");
|
||||||
|
assert_eq!(p.display_name(), "YouTube");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn platform_from_url_unknown_is_other() {
|
||||||
|
let p = platform::Platform::from_url("https://example.com/x");
|
||||||
|
assert_eq!(p.dir_name(), "other");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue