feat(android): installable demo APK exercising the Rust JNI core
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>
This commit is contained in:
parent
9a613239f2
commit
1790fd1a22
7 changed files with 362 additions and 0 deletions
23
android/demo/AndroidManifest.xml
Normal file
23
android/demo/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.catacomb.spike"
|
||||
android:versionCode="1"
|
||||
android:versionName="0.1-spike">
|
||||
|
||||
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="34" />
|
||||
|
||||
<application
|
||||
android:label="Catacomb Spike"
|
||||
android:icon="@android:drawable/sym_def_app_icon"
|
||||
android:extractNativeLibs="true"
|
||||
android:allowBackup="false">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
58
android/demo/README.md
Normal file
58
android/demo/README.md
Normal file
|
|
@ -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/<abi>/*.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.
|
||||
113
android/demo/build-apk.sh
Executable file
113
android/demo/build-apk.sh
Executable file
|
|
@ -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/<abi>/, 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/<abi>/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"
|
||||
123
android/demo/src/com/catacomb/spike/MainActivity.java
Normal file
123
android/demo/src/com/catacomb/spike/MainActivity.java
Normal file
|
|
@ -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).
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
32
android/demo/src/com/catacomb/spike/RustCore.java
Normal file
32
android/demo/src/com/catacomb/spike/RustCore.java
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package com.catacomb.spike;
|
||||
|
||||
/**
|
||||
* JNI binding for the Catacomb Rust core ({@code libcatacomb_core.so}).
|
||||
*
|
||||
* <p>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_<name>} 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/<abi>/ 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue