diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..d33be35 --- /dev/null +++ b/android/.gitignore @@ -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/ diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..92c81b9 --- /dev/null +++ b/android/README.md @@ -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// +./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/` 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_` 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). diff --git a/android/app/src/main/java/com/catacomb/spike/RustCore.kt b/android/app/src/main/java/com/catacomb/spike/RustCore.kt new file mode 100644 index 0000000..c807fd9 --- /dev/null +++ b/android/app/src/main/java/com/catacomb/spike/RustCore.kt @@ -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_` + * 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": , "text": }, …]`. + * 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": , "label": , "hint": }`. + * `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": , "display_name": , "icon": }`. + */ + 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 +} diff --git a/android/rust/catacomb_core/Cargo.lock b/android/rust/catacomb_core/Cargo.lock new file mode 100644 index 0000000..6f12389 --- /dev/null +++ b/android/rust/catacomb_core/Cargo.lock @@ -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" diff --git a/android/rust/catacomb_core/Cargo.toml b/android/rust/catacomb_core/Cargo.toml new file mode 100644 index 0000000..81cc408 --- /dev/null +++ b/android/rust/catacomb_core/Cargo.toml @@ -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 diff --git a/android/rust/catacomb_core/build.sh b/android/rust/catacomb_core/build.sh new file mode 100755 index 0000000..9f3fd2a --- /dev/null +++ b/android/rust/catacomb_core/build.sh @@ -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/ 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//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." diff --git a/android/rust/catacomb_core/src/lib.rs b/android/rust/catacomb_core/src/lib.rs new file mode 100644 index 0000000..6cfe202 --- /dev/null +++ b/android/rust/catacomb_core/src/lib.rs @@ -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 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 = 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 = 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"); + } +}