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
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