From 1790fd1a228de2157a403c461be62092ea774d24 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 3 Jul 2026 16:33:51 -0700 Subject: [PATCH] feat(android): installable demo APK exercising the Rust JNI core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add android/demo — a minimal, dependency-free app (plain Android Views, no Gradle/AGP/AndroidX/Compose) that calls the Phase-4 JNI core on-device and displays the results. build-apk.sh drives the SDK build-tools directly (aapt2 -> javac -> d8 -> zip -> zipalign -> apksigner), compiling against android.jar alone so it needs no network and tolerates a very new system JDK by routing the SDK tools through a JDK <= 21. The app has three buttons -> platformFromUrl/platformDirName, classifyError, and vttParse, each calling libcatacomb_core.so and showing the JSON. Verified on an Android 14 (API 34) x86_64 emulator: installs, launches, loads the .so, and returns correct JSON on-device ({"dir_name":"channels","display_name":"YouTube","icon":"..."} for a YouTube URL). Output APK (out/, git-ignored): catacomb-spike-debug.apk, v2+v3 signed, minSdk 24, native code for arm64-v8a + x86_64. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- android/.gitignore | 4 + android/README.md | 9 ++ android/demo/AndroidManifest.xml | 23 ++++ android/demo/README.md | 58 +++++++++ android/demo/build-apk.sh | 113 ++++++++++++++++ .../src/com/catacomb/spike/MainActivity.java | 123 ++++++++++++++++++ .../demo/src/com/catacomb/spike/RustCore.java | 32 +++++ 7 files changed, 362 insertions(+) create mode 100644 android/demo/AndroidManifest.xml create mode 100644 android/demo/README.md create mode 100755 android/demo/build-apk.sh create mode 100644 android/demo/src/com/catacomb/spike/MainActivity.java create mode 100644 android/demo/src/com/catacomb/spike/RustCore.java diff --git a/android/.gitignore b/android/.gitignore index d33be35..26c6d71 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -5,6 +5,10 @@ rust/catacomb_core/target/ # rather than commit binaries. app/src/main/jniLibs/ +# Manual APK build outputs (APK, signing artifacts, debug keystore) — build +# products, never committed. +demo/out/ + # Gradle / Android build outputs (Phase 1, not yet scaffolded here) .gradle/ build/ diff --git a/android/README.md b/android/README.md index 92c81b9..709510d 100644 --- a/android/README.md +++ b/android/README.md @@ -18,6 +18,15 @@ Phases 2/3/5 require a physical Android device with a logged-in Google session environment. Phase 4 — the "shared Rust core" leg the feasibility research marked **PROVEN** — is fully implemented and verified here. +## Installable demo APK (`demo/`) + +[`demo/`](demo/) is a minimal, dependency-free app (plain Android Views, no +Gradle/AGP/Compose) that calls the Phase-4 JNI core on-device and shows the +results. Build it with `demo/build-apk.sh` (drives `aapt2`/`d8`/`zipalign`/ +`apksigner` directly) → `demo/out/catacomb-spike-debug.apk`. Verified on an +Android 14 x86_64 emulator: the app launches, loads `libcatacomb_core.so`, and +the JNI calls return correct JSON. See [`demo/README.md`](demo/README.md). + ## Phase 4: `rust/catacomb_core` A `cdylib` that reuses Catacomb's **pure** modules verbatim (`vtt`, diff --git a/android/demo/AndroidManifest.xml b/android/demo/AndroidManifest.xml new file mode 100644 index 0000000..b731331 --- /dev/null +++ b/android/demo/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + diff --git a/android/demo/README.md b/android/demo/README.md new file mode 100644 index 0000000..284ed8c --- /dev/null +++ b/android/demo/README.md @@ -0,0 +1,58 @@ +# Catacomb Rust-core demo APK + +A minimal, installable Android app that exercises the Phase-4 Rust JNI core +(`libcatacomb_core.so`) **on-device**. It's intentionally dependency-free — plain +Android Views, no Gradle / AGP / AndroidX / Compose — so it builds against +`android.jar` alone by driving the SDK build-tools directly. This sidesteps +AGP's toolchain constraints (e.g. a very new system JDK) and needs no network. + +This is **not** the full Stage-1 app (no `yt-dlp-android`, no WebView-BotGuard — +those are Phases 1/2/3/5 and are device-with-Google-session territory). It's a +focused proof that the shared Rust modules run on Android through JNI. + +## What it does + +Three buttons call into Rust and show the JSON that comes back: + +- **Detect platform** → `RustCore.platformFromUrl(url)` + `platformDirName(url)` +- **Classify error** → `RustCore.classifyError(log)` +- **Parse sample VTT** → `RustCore.vttParse(sample)` + +## Build + +```bash +# 1. Build the native libs (arm64-v8a + x86_64) if not already present: +../rust/catacomb_core/build.sh + +# 2. Build the signed, installable APK: +./build-apk.sh # → out/catacomb-spike-debug.apk +``` + +Requirements (auto-detected): an Android SDK with `platforms;android-34` and +`build-tools;34.0.0`, and a JDK ≤ 21 for the SDK tools (R8/`d8` and `apksigner` +choke on bleeding-edge JDKs; the script prefers `/usr/lib/jvm/java-17-openjdk`, +override with `TOOL_JAVA_HOME`). + +The pipeline is: `aapt2 link` (manifest + resources) → `javac` (against +`android.jar`) → `d8` (dex) → `zip` in `classes.dex` + `lib//*.so` → +`zipalign` → `apksigner` (debug key). Output is v2+v3 signed, minSdk 24. + +## Install & run + +```bash +adb install -r out/catacomb-spike-debug.apk +adb shell am start -n com.catacomb.spike/.MainActivity +``` + +Verified on an Android 14 (API 34) x86_64 emulator: the app launches, the `.so` +loads, and the startup probe + button calls return correct JSON +(`{"dir_name":"channels","display_name":"YouTube","icon":"▶"}` for a YouTube URL). + +## Notes + +- `src/com/catacomb/spike/RustCore.java` is the Java twin of the Kotlin + `../app/.../RustCore.kt`; both bind the same `Java_com_catacomb_spike_RustCore_*` + native symbols, so the identical `.so` serves either. Only the Java one is + compiled by this manual build (no `kotlinc` needed). +- `out/` (APK, `.idsig`, debug keystore) is git-ignored — rebuild rather than + commit binaries. diff --git a/android/demo/build-apk.sh b/android/demo/build-apk.sh new file mode 100755 index 0000000..a975b7e --- /dev/null +++ b/android/demo/build-apk.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Build an installable, debug-signed APK for the Catacomb Rust-core JNI demo, +# driving the SDK build-tools directly (aapt2 → javac → d8 → zip → zipalign → +# apksigner). No Gradle / AGP / AndroidX — the app uses only android.jar, so +# this sidesteps AGP's toolchain constraints (e.g. very new JDKs). +# +# Prereqs (auto-detected): an Android SDK with platforms;android-34 and +# build-tools;34.0.0, plus the native libs built by +# ../rust/catacomb_core/build.sh (which populates ../app/src/main/jniLibs/). +# +# Usage: ./build-apk.sh → out/catacomb-spike-debug.apk +set -euo pipefail +cd "$(dirname "$0")" + +PKG_PATH="com/catacomb/spike" +BUILD_TOOLS_VER="${BUILD_TOOLS_VER:-34.0.0}" +API="${API:-34}" + +# ── Locate the SDK ──────────────────────────────────────────────────────── +SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}" +[[ -d "$SDK" ]] || { echo "ERROR: Android SDK not found (set ANDROID_HOME)."; exit 1; } +BT="$SDK/build-tools/$BUILD_TOOLS_VER" +ANDROID_JAR="$SDK/platforms/android-$API/android.jar" +for f in "$BT/aapt2" "$BT/d8" "$BT/zipalign" "$BT/apksigner" "$ANDROID_JAR"; do + [[ -e "$f" ]] || { echo "ERROR: missing $f"; echo "Install: sdkmanager 'build-tools;$BUILD_TOOLS_VER' 'platforms;android-$API'"; exit 1; } +done + +# ── Pick a JDK the SDK tools tolerate ───────────────────────────────────── +# R8/d8 (and apksigner) choke on very new JDKs (e.g. 26 → internal NPE), so +# prefer an LTS ≤ 21. The build-tools wrapper scripts honour JAVA_HOME; javac +# is invoked explicitly with the same JDK. Override with TOOL_JAVA_HOME. +find_tool_jdk() { + if [[ -n "${TOOL_JAVA_HOME:-}" && -x "$TOOL_JAVA_HOME/bin/java" ]]; then + echo "$TOOL_JAVA_HOME"; return + fi + local c + for c in java-17-openjdk java-21-openjdk java-11-openjdk; do + [[ -x "/usr/lib/jvm/$c/bin/java" ]] && { echo "/usr/lib/jvm/$c"; return; } + done + # Fall back to whatever `java` is on PATH (may fail on bleeding-edge JDKs). + local p; p="$(command -v java || true)" + [[ -n "$p" ]] && echo "$(dirname "$(dirname "$(readlink -f "$p")")")" +} +export JAVA_HOME="$(find_tool_jdk)" +JAVAC="$JAVA_HOME/bin/javac" +KEYTOOL="$JAVA_HOME/bin/keytool" +[[ -x "$JAVA_HOME/bin/java" ]] || { echo "ERROR: no usable JDK (set TOOL_JAVA_HOME)."; exit 1; } +echo "Using JDK for SDK tools: $JAVA_HOME" + +# Native libs produced by the Rust cross-compile step. +JNILIBS="../app/src/main/jniLibs" +if ! ls "$JNILIBS"/*/libcatacomb_core.so >/dev/null 2>&1; then + echo "ERROR: no native libs in $JNILIBS. Run ../rust/catacomb_core/build.sh first."; exit 1 +fi + +OUT="out"; WORK="$OUT/work" +rm -rf "$OUT"; mkdir -p "$WORK/classes" "$WORK/dex" "$WORK/apk" + +echo "── 1/6 aapt2: link resources + manifest ──────────────────────" +"$BT/aapt2" link \ + -I "$ANDROID_JAR" \ + --manifest AndroidManifest.xml \ + --min-sdk-version 24 --target-sdk-version "$API" \ + -o "$WORK/base.apk" + +echo "── 2/6 javac: compile Java against android.jar ───────────────" +mapfile -t SRCS < <(find src -name '*.java') +"$JAVAC" --release 11 -Xlint:-options \ + -classpath "$ANDROID_JAR" \ + -d "$WORK/classes" "${SRCS[@]}" + +echo "── 3/6 d8: dex the classes ───────────────────────────────────" +mapfile -t CLASSES < <(find "$WORK/classes" -name '*.class') +"$BT/d8" --release --min-api 24 --lib "$ANDROID_JAR" \ + --output "$WORK/dex" "${CLASSES[@]}" + +echo "── 4/6 assemble: dex + native libs into the APK ──────────────" +# aapt2 already produced base.apk (manifest + resources.arsc). Add classes.dex +# at the archive root and the .so files under lib//, then this is a +# complete (unaligned, unsigned) APK. +cp "$WORK/base.apk" "$WORK/unsigned.apk" +( cd "$WORK/dex" && zip -q "$OLDPWD/$WORK/unsigned.apk" classes.dex ) +# Stage lib//libcatacomb_core.so from every built ABI. +LIBROOT="$WORK/apk" +for so in "$JNILIBS"/*/libcatacomb_core.so; do + abi="$(basename "$(dirname "$so")")" + mkdir -p "$LIBROOT/lib/$abi" + cp "$so" "$LIBROOT/lib/$abi/" +done +( cd "$LIBROOT" && zip -q -r "$OLDPWD/$WORK/unsigned.apk" lib ) + +echo "── 5/6 zipalign ──────────────────────────────────────────────" +"$BT/zipalign" -p -f 4 "$WORK/unsigned.apk" "$WORK/aligned.apk" + +echo "── 6/6 apksigner: debug-sign ─────────────────────────────────" +KS="$OUT/debug.keystore" +if [[ ! -f "$KS" ]]; then + "$KEYTOOL" -genkeypair -keystore "$KS" -alias androiddebugkey \ + -storepass android -keypass android \ + -keyalg RSA -keysize 2048 -validity 10000 \ + -dname "CN=Android Debug,O=Android,C=US" >/dev/null 2>&1 +fi +APK="$OUT/catacomb-spike-debug.apk" +"$BT/apksigner" sign \ + --ks "$KS" --ks-pass pass:android --key-pass pass:android \ + --min-sdk-version 24 \ + --out "$APK" "$WORK/aligned.apk" +"$BT/apksigner" verify --min-sdk-version 24 "$APK" && echo "signature OK" + +rm -rf "$WORK" +echo "" +echo "✓ APK: $(cd "$(dirname "$APK")" && pwd)/$(basename "$APK") ($(du -h "$APK" | cut -f1))" +echo " Install: adb install -r $APK" diff --git a/android/demo/src/com/catacomb/spike/MainActivity.java b/android/demo/src/com/catacomb/spike/MainActivity.java new file mode 100644 index 0000000..fb74a66 --- /dev/null +++ b/android/demo/src/com/catacomb/spike/MainActivity.java @@ -0,0 +1,123 @@ +package com.catacomb.spike; + +import android.app.Activity; +import android.graphics.Color; +import android.graphics.Typeface; +import android.os.Bundle; +import android.text.InputType; +import android.view.Gravity; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; + +/** + * Minimal demo that exercises the Rust JNI core on-device (Stage-1, Phase 4). + * + *

The UI is built programmatically (no layout XML / AndroidX / Compose) so + * the APK compiles against {@code android.jar} alone and stays dependency-free. + * It lets you type a URL / paste a yt-dlp error and see the results the shared + * Rust modules ({@code platform}, {@code error_class}, {@code vtt}) return + * through the JNI bridge. + */ +public class MainActivity extends Activity { + + private TextView output; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + int pad = dp(16); + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setPadding(pad, pad, pad, pad); + + TextView title = new TextView(this); + title.setText("Catacomb — Rust core (JNI) demo"); + title.setTextSize(20); + title.setTypeface(Typeface.DEFAULT_BOLD); + title.setPadding(0, 0, 0, dp(12)); + root.addView(title); + + // ── Platform detection ───────────────────────────────────────────── + final EditText urlField = new EditText(this); + urlField.setHint("Media URL"); + urlField.setText("https://youtu.be/dQw4w9WgXcQ"); + urlField.setInputType(InputType.TYPE_TEXT_VARIATION_URI); + root.addView(urlField); + + Button detectBtn = new Button(this); + detectBtn.setText("Detect platform"); + detectBtn.setOnClickListener(new View.OnClickListener() { + @Override public void onClick(View v) { + String url = urlField.getText().toString(); + show("platformFromUrl:\n" + RustCore.platformFromUrl(url) + + "\n\nplatformDirName: " + RustCore.platformDirName(url)); + } + }); + root.addView(detectBtn); + + // ── Error classification ─────────────────────────────────────────── + final EditText logField = new EditText(this); + logField.setHint("yt-dlp log line(s)"); + logField.setText("ERROR: HTTP Error 429: Too Many Requests"); + logField.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE); + root.addView(logField); + + Button classifyBtn = new Button(this); + classifyBtn.setText("Classify error"); + classifyBtn.setOnClickListener(new View.OnClickListener() { + @Override public void onClick(View v) { + show("classifyError:\n" + RustCore.classifyError(logField.getText().toString())); + } + }); + root.addView(classifyBtn); + + // ── VTT parse ────────────────────────────────────────────────────── + Button vttBtn = new Button(this); + vttBtn.setText("Parse sample VTT"); + vttBtn.setOnClickListener(new View.OnClickListener() { + @Override public void onClick(View v) { + String sample = "WEBVTT\n\n00:00:01.000 --> 00:00:03.000\nHello from Rust\n\n" + + "00:00:04.500 --> 00:00:06.000\nSecond cue\n"; + show("vttParse:\n" + RustCore.vttParse(sample)); + } + }); + root.addView(vttBtn); + + // ── Output ───────────────────────────────────────────────────────── + output = new TextView(this); + output.setPadding(0, dp(16), 0, 0); + output.setTextIsSelectable(true); + output.setTypeface(Typeface.MONOSPACE); + output.setText(loadBanner()); + + ScrollView scroll = new ScrollView(this); + scroll.addView(output); + root.addView(scroll); + + setContentView(root); + } + + /** Confirm the .so actually loaded by calling into it once at startup. */ + private String loadBanner() { + try { + String probe = RustCore.platformDirName("https://youtu.be/x"); + return "libcatacomb_core.so loaded ✓ (probe → \"" + probe + "\")\n" + + "Tap a button to call the Rust core."; + } catch (Throwable t) { + return "Failed to call Rust core: " + t; + } + } + + private void show(String s) { + output.setText(s); + } + + private int dp(int v) { + return Math.round(v * getResources().getDisplayMetrics().density); + } +} diff --git a/android/demo/src/com/catacomb/spike/RustCore.java b/android/demo/src/com/catacomb/spike/RustCore.java new file mode 100644 index 0000000..2ab1511 --- /dev/null +++ b/android/demo/src/com/catacomb/spike/RustCore.java @@ -0,0 +1,32 @@ +package com.catacomb.spike; + +/** + * JNI binding for the Catacomb Rust core ({@code libcatacomb_core.so}). + * + *

Java twin of {@code RustCore.kt}. Only one is compiled per build system — + * the Kotlin object is for the future Gradle/Compose app; this Java class is + * what the manually-built demo APK (see {@code android/demo/}) actually + * compiles. Both resolve to the identical native symbols + * {@code Java_com_catacomb_spike_RustCore_} in the shared library, so the + * same {@code .so} serves either. + */ +public final class RustCore { + static { + // Loads libcatacomb_core.so from the APK's lib// directory. + System.loadLibrary("catacomb_core"); + } + + private RustCore() {} + + /** VTT/SRT text → JSON array {@code [{"start":Double,"text":String}, …]}. */ + public static native String vttParse(String vtt); + + /** yt-dlp failure log (one entry per line) → JSON {@code {class,label,hint}}. */ + public static native String classifyError(String log); + + /** URL → platform descriptor JSON {@code {dir_name,display_name,icon}}. */ + public static native String platformFromUrl(String url); + + /** URL → backup-folder name (e.g. {@code channels} for YouTube). */ + public static native String platformDirName(String url); +}