diff --git a/android/.gitignore b/android/.gitignore index 26c6d71..9c9096e 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -5,14 +5,15 @@ 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. +# Manual APK build outputs (demo/) demo/out/ -# Gradle / Android build outputs (Phase 1, not yet scaffolded here) +# Gradle / Android build outputs .gradle/ build/ app/build/ local.properties +debug.keystore *.iml .idea/ +captures/ diff --git a/android/README.md b/android/README.md index 709510d..61d8e6b 100644 --- a/android/README.md +++ b/android/README.md @@ -1,87 +1,76 @@ -# Catacomb Android (Stage-1 prototype) +# Catacomb Android -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). +The Catacomb Android app: a Compose UI over a **bundled on-device yt-dlp engine** +(Python + yt-dlp + ffmpeg + aria2c via [youtubedl-android]) plus a **shared Rust +core** (`vtt`, `error_class`, `platform`) called over JNI. -## Status by phase +## What lives here + +- **`app/`** — the Gradle app (Kotlin + Jetpack Compose + Material3). The real, + installable app. +- **`rust/catacomb_core/`** — the Rust `cdylib` (Phase 4) reused verbatim from the + desktop's pure modules, cross-compiled to a JNI `.so`. See its own README. +- **`demo/`** — a tiny dependency-free Java app that only exercises the Rust core + (a minimal, Gradle-free reference; predates the full app). + +## Features + +- **Bundled yt-dlp** — real on-device download engine, no server needed. Extracts + its Python environment on first launch. +- **Intuitive navigation** — bottom navigation bar: **Download**, **Files**, + **Settings**. +- **All desktop themes** — the 19 palettes from `src/theme.rs` (Dark, Light, + Dracula, Trans, the Emo/goth set, Cyberpunk, Synthwave, Vaporwave, Nord, + Gruvbox, Tokyo Night, Paper, Honey, Candlelight) selectable as live swatches; + the choice is persisted. +- **Live platform detection** — as you type a URL, the Rust core resolves its + platform (chip shows icon + name + folder). +- **Improved progress indicator** — animated determinate bar with %, ETA, live + yt-dlp status line, success/error end states, plus a scrollable log. + +## Build + +```bash +./build-apk.sh # -> app/build/outputs/apk/debug/app-debug.apk (~130 MB) +``` + +`build-apk.sh`: +- runs Gradle on a JDK <= 21 (the system JDK may be newer and break R8/d8; + override with `TOOL_JAVA_HOME`), +- builds the Rust native libs first if missing (`rust/catacomb_core/build.sh`), +- writes `local.properties` (SDK path) and a stable `debug.keystore`, +- runs `./gradlew assembleDebug`. + +Toolchain (pinned): Gradle 8.9 (wrapper), AGP 8.5.2, Kotlin 1.9.24, Compose +compiler 1.5.14, Compose BOM 2024.06.00, compileSdk 34, minSdk 24. ABIs: +arm64-v8a (devices) + x86_64 (emulator) — the intersection of what the Rust core +and youtubedl-android ship. + +## Install & run + +```bash +adb install -r app/build/outputs/apk/debug/app-debug.apk +adb shell am start -n com.catacomb.spike/.MainActivity +``` + +Verified on an Android 14 (API 34) x86_64 emulator: installs, launches, the +bundled engine initialises ("yt-dlp ready"), the Rust `.so` loads (live platform +detection), theme switching re-themes the whole app and persists across restart, +and all three screens render. + +## Status vs the Stage-1 plan | 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 | +| 1 | App shell + yt-dlp-android integration | Done (this app) | +| 2 | WebView-BotGuard POT generation | Not done — needs a real device + Google session | +| 3 | Token passing + anti-bot test | Not done — device-only | +| 4 | Rust core -> Android `.so` via JNI | Done (`rust/catacomb_core`) | +| 5 | On-device Go/No-Go | Partial — app verified on emulator; anti-bot download test is 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. +The engine is bundled and initialises on-device; a *successful YouTube download* +still depends on the anti-bot/POT question (Phases 2-3), which needs a physical +device with a signed-in Google session — the open item from the feasibility +research. -## 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`, -`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). +[youtubedl-android]: https://github.com/junkfood02/youtubedl-android diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..78f58e7 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,100 @@ +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.catacomb.spike" + compileSdk = 34 + + defaultConfig { + applicationId = "com.catacomb.spike" + minSdk = 24 + targetSdk = 34 + versionCode = 2 + versionName = "0.2-spike" + // youtubedl-android ships native Python for these ABIs; the Rust core + // (jniLibs/) ships arm64-v8a + x86_64. Restrict to the intersection so + // every ABI in the APK has both libraries. + ndk { + abiFilters += listOf("arm64-v8a", "x86_64") + } + } + + signingConfigs { + // Reproducible debug key so `assembleDebug` produces an installable, + // stably-signed APK without Android Studio. Generated on first build + // by build-apk.sh if absent. + create("debugks") { + val ks = rootProject.file("debug.keystore") + if (ks.exists()) { + storeFile = ks + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } + } + } + + buildTypes { + getByName("debug") { + isMinifyEnabled = false + if (rootProject.file("debug.keystore").exists()) { + signingConfig = signingConfigs.getByName("debugks") + } + } + getByName("release") { + isMinifyEnabled = false + signingConfig = signingConfigs.getByName("debug") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + } + composeOptions { + // Matches Kotlin 1.9.24. + kotlinCompilerExtensionVersion = "1.5.14" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + // youtubedl-android extracts its bundled Python/ffmpeg/aria2c payloads + // (libpython.zip.so, …) from the native lib dir at first launch, so the + // .so files MUST be extracted to disk — i.e. legacy packaging on + // (extractNativeLibs=true). With them left compressed-in-APK the engine + // init fails. + jniLibs { + useLegacyPackaging = true + } + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.06.00") + implementation(composeBom) + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.activity:activity-compose:1.9.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.2") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.2") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + + // On-device yt-dlp engine (bundled Python + yt-dlp) + ffmpeg + aria2c. + implementation("io.github.junkfood02.youtubedl-android:library:0.18.1") + implementation("io.github.junkfood02.youtubedl-android:ffmpeg:0.18.1") + implementation("io.github.junkfood02.youtubedl-android:aria2c:0.18.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..da90fd7 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/catacomb/spike/CatacombApp.kt b/android/app/src/main/java/com/catacomb/spike/CatacombApp.kt new file mode 100644 index 0000000..0f2e4ec --- /dev/null +++ b/android/app/src/main/java/com/catacomb/spike/CatacombApp.kt @@ -0,0 +1,11 @@ +package com.catacomb.spike + +import android.app.Application + +/** Kicks off the bundled yt-dlp engine init as early as possible. */ +class CatacombApp : Application() { + override fun onCreate() { + super.onCreate() + Engine.init(this) + } +} diff --git a/android/app/src/main/java/com/catacomb/spike/Common.kt b/android/app/src/main/java/com/catacomb/spike/Common.kt new file mode 100644 index 0000000..67ca091 --- /dev/null +++ b/android/app/src/main/java/com/catacomb/spike/Common.kt @@ -0,0 +1,54 @@ +package com.catacomb.spike + +import android.content.Context +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.json.JSONObject +import java.io.File + +/** App-private external download directory (no runtime permission needed). */ +fun downloadDir(context: Context): File = + context.getExternalFilesDir("downloads") ?: File(context.filesDir, "downloads") + +/** Safe extract of a string field from a small JSON object string. */ +fun jsonField(json: String, key: String): String = + runCatching { JSONObject(json).optString(key, "") }.getOrDefault("") + +/** A titled surface card used to group content into sections. */ +@Composable +fun SectionCard( + title: String, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column(Modifier.padding(16.dp)) { + Text( + title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 10.dp), + ) + content() + } + } +} + +/** Standard screen content padding. */ +val screenPadding = PaddingValues(16.dp) diff --git a/android/app/src/main/java/com/catacomb/spike/DownloadScreen.kt b/android/app/src/main/java/com/catacomb/spike/DownloadScreen.kt new file mode 100644 index 0000000..3142bc3 --- /dev/null +++ b/android/app/src/main/java/com/catacomb/spike/DownloadScreen.kt @@ -0,0 +1,256 @@ +package com.catacomb.spike + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +/** Download stage for the progress card. */ +private sealed interface DlStage { + data object Idle : DlStage + data object Running : DlStage + data class Done(val ok: Boolean, val message: String) : DlStage +} + +@Composable +fun DownloadScreen() { + val context = LocalContext.current + val prefs = remember { Prefs(context) } + val scope = rememberCoroutineScope() + + var url by remember { mutableStateOf("") } + var stage by remember { mutableStateOf(DlStage.Idle) } + var progress by remember { mutableStateOf(0f) } + var etaSeconds by remember { mutableStateOf(0L) } + var statusLine by remember { mutableStateOf("") } + val log = remember { mutableStateOf("") } + + // Live platform detection through the Rust core, as the URL is typed. + val platform = remember(url) { + if (url.isBlank()) null + else runCatching { RustCore.platformFromUrl(url) }.getOrNull() + } + + val engineState = Engine.state + val engineReady = engineState is EngineState.Ready + val running = stage is DlStage.Running + + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // ── Engine status banner ──────────────────────────────────────── + EngineBanner(engineState) + + // ── URL + platform chip ───────────────────────────────────────── + SectionCard("Source") { + OutlinedTextField( + value = url, + onValueChange = { url = it }, + label = { Text("Video / playlist URL") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + if (platform != null) { + Spacer(Modifier.height(10.dp)) + val name = jsonField(platform, "display_name").ifEmpty { "Unknown" } + val icon = jsonField(platform, "icon") + val dir = jsonField(platform, "dir_name") + AssistChip( + onClick = {}, + label = { Text("$icon $name · $dir/") }, + colors = AssistChipDefaults.assistChipColors( + labelColor = MaterialTheme.colorScheme.primary, + ), + ) + } + } + + // ── Action ────────────────────────────────────────────────────── + val quality = Quality.byId(prefs.quality) + Button( + onClick = { + stage = DlStage.Running + progress = 0f + etaSeconds = 0L + statusLine = "Starting…" + log.value = "" + scope.launch { + val result = Engine.download( + url = url.trim(), + destDir = downloadDir(context), + quality = quality, + ) { p, eta, line -> + progress = (p / 100f).coerceIn(0f, 1f) + etaSeconds = eta + if (line.isNotBlank()) { + statusLine = line + log.value = (log.value + line + "\n").takeLast(6000) + } + } + stage = DlStage.Done(result.ok, result.message) + if (result.ok) progress = 1f + } + }, + enabled = engineReady && url.isNotBlank() && !running, + modifier = Modifier.fillMaxWidth(), + ) { + Text(if (running) "Downloading…" else "Download · ${quality.label}") + } + + // ── Progress card ─────────────────────────────────────────────── + AnimatedVisibility(visible = stage !is DlStage.Idle) { + ProgressCard(stage, progress, etaSeconds, statusLine) + } + + // ── Log ───────────────────────────────────────────────────────── + if (log.value.isNotEmpty()) { + SectionCard("Log") { + Text( + log.value, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun EngineBanner(state: EngineState) { + when (state) { + is EngineState.Initializing -> SectionCard("Engine") { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator( + modifier = Modifier.height(18.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.height(0.dp)) + Text( + " Preparing yt-dlp engine (first launch extracts Python)…", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + is EngineState.Ready -> SectionCard("Engine") { + Text( + "yt-dlp ready · ${state.version}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary, + ) + } + is EngineState.Failed -> SectionCard("Engine") { + Text( + "Engine failed to initialise: ${state.message}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } +} + +/** The improved determinate progress indicator: animated bar + %, ETA, status. */ +@Composable +private fun ProgressCard(stage: DlStage, progress: Float, etaSeconds: Long, statusLine: String) { + val animated by animateFloatAsState(targetValue = progress, label = "dl-progress") + val done = stage as? DlStage.Done + + SectionCard(if (done != null) "Result" else "Downloading") { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "${(animated * 100).toInt()}%", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.primary, + ) + when { + done?.ok == true -> Icon( + Icons.Filled.CheckCircle, contentDescription = "done", + tint = MaterialTheme.colorScheme.secondary, + ) + done != null -> Icon( + Icons.Filled.Error, contentDescription = "error", + tint = MaterialTheme.colorScheme.tertiary, + ) + etaSeconds > 0 -> Text( + "ETA ${formatEta(etaSeconds)}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(Modifier.height(10.dp)) + LinearProgressIndicator( + progress = { animated }, + modifier = Modifier + .fillMaxWidth() + .height(10.dp), + trackColor = MaterialTheme.colorScheme.surface, + strokeCap = androidx.compose.ui.graphics.StrokeCap.Round, + ) + Spacer(Modifier.height(10.dp)) + Text( + done?.message ?: statusLine.ifEmpty { "Working…" }, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = when { + done?.ok == true -> MaterialTheme.colorScheme.secondary + done != null -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.onSurface + }, + ) + } +} + +private fun formatEta(seconds: Long): String { + if (seconds <= 0) return "—" + val m = seconds / 60 + val s = seconds % 60 + return if (m > 0) "${m}m ${s}s" else "${s}s" +} + +// Suppress unused shape import guard. +@Suppress("unused") +private val cardShape = RoundedCornerShape(16.dp) diff --git a/android/app/src/main/java/com/catacomb/spike/Engine.kt b/android/app/src/main/java/com/catacomb/spike/Engine.kt new file mode 100644 index 0000000..3c7c840 --- /dev/null +++ b/android/app/src/main/java/com/catacomb/spike/Engine.kt @@ -0,0 +1,91 @@ +package com.catacomb.spike + +import android.app.Application +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.yausername.ffmpeg.FFmpeg +import com.yausername.youtubedl_android.YoutubeDL +import com.yausername.youtubedl_android.YoutubeDLRequest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +/** Lifecycle of the bundled on-device yt-dlp engine. */ +sealed interface EngineState { + data object Initializing : EngineState + data class Ready(val version: String) : EngineState + data class Failed(val message: String) : EngineState +} + +/** + * Wraps the bundled youtubedl-android engine (Python + yt-dlp + ffmpeg). + * + * `init()` extracts the bundled Python environment on first launch (slow, so it + * runs off the main thread) and flips [state] to [EngineState.Ready]. The UI + * observes [state] to enable the Download button and show status. + */ +object Engine { + var state by mutableStateOf(EngineState.Initializing) + private set + + private val io = CoroutineScope(Dispatchers.IO) + + fun init(app: Application) { + io.launch { + state = try { + YoutubeDL.getInstance().init(app) + FFmpeg.getInstance().init(app) + val v = runCatching { YoutubeDL.getInstance().version(app) }.getOrNull() ?: "unknown" + android.util.Log.i("CatacombEngine", "yt-dlp ready: $v") + EngineState.Ready(v) + } catch (t: Throwable) { + android.util.Log.e("CatacombEngine", "init failed", t) + EngineState.Failed(t.message ?: t.javaClass.simpleName) + } + } + } + + val isReady: Boolean get() = state is EngineState.Ready + + /** Result of a download attempt. */ + data class DownloadResult(val ok: Boolean, val message: String) + + /** + * Run a download. Must be called from a coroutine; the blocking yt-dlp call + * is dispatched to IO. [onProgress] is invoked with (percent 0..100, eta + * seconds, latest output line) on the engine's callback thread. + */ + suspend fun download( + url: String, + destDir: File, + quality: Quality, + onProgress: (Float, Long, String) -> Unit, + ): DownloadResult = withContext(Dispatchers.IO) { + if (state !is EngineState.Ready) { + return@withContext DownloadResult(false, "Engine not ready") + } + if (!destDir.exists()) destDir.mkdirs() + try { + val request = YoutubeDLRequest(url) + request.addOption("-o", "${destDir.absolutePath}/%(title).200s.%(ext)s") + request.addOption("-f", quality.format) + if (quality.audioOnly) { + request.addOption("-x") + request.addOption("--audio-format", "m4a") + } + // Keep runs bounded and quiet-ish for the log view. + request.addOption("--no-playlist") + request.addOption("--newline") + val processId = "catacomb-${System.currentTimeMillis()}" + YoutubeDL.getInstance().execute(request, processId) { progress, etaSeconds, line -> + onProgress(progress, etaSeconds, line) + } + DownloadResult(true, "Download complete") + } catch (t: Throwable) { + DownloadResult(false, t.message ?: t.javaClass.simpleName) + } + } +} diff --git a/android/app/src/main/java/com/catacomb/spike/FilesScreen.kt b/android/app/src/main/java/com/catacomb/spike/FilesScreen.kt new file mode 100644 index 0000000..f713fa6 --- /dev/null +++ b/android/app/src/main/java/com/catacomb/spike/FilesScreen.kt @@ -0,0 +1,96 @@ +package com.catacomb.spike + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.util.Locale + +/** Lists files already downloaded into the app's download directory. */ +@Composable +fun FilesScreen() { + val context = LocalContext.current + val dir = rememberDownloadDir(context) + + // Re-list on each composition entry (cheap; dir is small). + val files by produceState(initialValue = emptyList(), dir) { + value = withContext(Dispatchers.IO) { + dir.listFiles()?.sortedByDescending { it.lastModified() }?.toList() ?: emptyList() + } + } + + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + SectionCard("Downloads") { + Text( + dir.absolutePath, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(bottom = 8.dp), + ) + if (files.isEmpty()) { + Text( + "No downloads yet. Grab something from the Download tab.", + style = MaterialTheme.typography.bodyMedium, + ) + } else { + files.forEachIndexed { i, f -> + if (i > 0) Divider(Modifier.padding(vertical = 6.dp)) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + f.name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(end = 12.dp), + ) + Text( + humanSize(f.length()), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun rememberDownloadDir(context: android.content.Context): File = downloadDir(context) + +private fun humanSize(bytes: Long): String { + if (bytes < 1024) return "$bytes B" + val units = listOf("KB", "MB", "GB", "TB") + var v = bytes.toDouble() / 1024 + var i = 0 + while (v >= 1024 && i < units.size - 1) { v /= 1024; i++ } + return String.format(Locale.US, "%.1f %s", v, units[i]) +} diff --git a/android/app/src/main/java/com/catacomb/spike/MainActivity.kt b/android/app/src/main/java/com/catacomb/spike/MainActivity.kt new file mode 100644 index 0000000..7db87e4 --- /dev/null +++ b/android/app/src/main/java/com/catacomb/spike/MainActivity.kt @@ -0,0 +1,99 @@ +package com.catacomb.spike + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext + +/** Bottom-navigation destinations. */ +private enum class Screen(val label: String, val icon: ImageVector) { + Download("Download", Icons.Filled.Download), + Files("Files", Icons.Filled.Folder), + Settings("Settings", Icons.Filled.Settings), +} + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { CatacombRoot() } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CatacombRoot() { + val context = LocalContext.current + val prefs = remember { Prefs(context) } + + // Hoisted app state — theme + current screen survive recomposition. + var themeId by remember { mutableStateOf(prefs.themeId) } + var current by remember { mutableStateOf(Screen.Download) } + val theme = themeById(themeId) + + CatacombTheme(theme) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("Catacomb · ${current.label}") }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.primary, + ), + ) + }, + bottomBar = { + NavigationBar(containerColor = MaterialTheme.colorScheme.surface) { + Screen.entries.forEach { screen -> + NavigationBarItem( + selected = current == screen, + onClick = { current = screen }, + icon = { Icon(screen.icon, contentDescription = screen.label) }, + label = { Text(screen.label) }, + ) + } + } + }, + ) { inner -> + Box(Modifier.fillMaxSize().padding(inner)) { + when (current) { + Screen.Download -> DownloadScreen() + Screen.Files -> FilesScreen() + Screen.Settings -> SettingsScreen( + currentThemeId = themeId, + currentQuality = prefs.quality, + onThemeSelected = { id -> + themeId = id + prefs.themeId = id + }, + onQualitySelected = { q -> prefs.quality = q }, + ) + } + } + } + } +} diff --git a/android/app/src/main/java/com/catacomb/spike/Prefs.kt b/android/app/src/main/java/com/catacomb/spike/Prefs.kt new file mode 100644 index 0000000..9140abc --- /dev/null +++ b/android/app/src/main/java/com/catacomb/spike/Prefs.kt @@ -0,0 +1,28 @@ +package com.catacomb.spike + +import android.content.Context + +/** Thin SharedPreferences wrapper for the handful of persisted settings. */ +class Prefs(context: Context) { + private val sp = context.getSharedPreferences("catacomb", Context.MODE_PRIVATE) + + var themeId: String + get() = sp.getString("theme", "dark") ?: "dark" + set(v) = sp.edit().putString("theme", v).apply() + + var quality: String + get() = sp.getString("quality", "best") ?: "best" + set(v) = sp.edit().putString("quality", v).apply() +} + +/** Download quality presets → yt-dlp format selectors. */ +enum class Quality(val id: String, val label: String, val format: String, val audioOnly: Boolean) { + BEST("best", "Best available", "bestvideo*+bestaudio/best", false), + P1080("1080p", "1080p", "bestvideo[height<=1080]+bestaudio/best[height<=1080]", false), + P720("720p", "720p", "bestvideo[height<=720]+bestaudio/best[height<=720]", false), + AUDIO("audio", "Audio only (m4a)", "bestaudio/best", true); + + companion object { + fun byId(id: String) = entries.firstOrNull { it.id == id } ?: BEST + } +} diff --git a/android/app/src/main/java/com/catacomb/spike/SettingsScreen.kt b/android/app/src/main/java/com/catacomb/spike/SettingsScreen.kt new file mode 100644 index 0000000..f8e87cb --- /dev/null +++ b/android/app/src/main/java/com/catacomb/spike/SettingsScreen.kt @@ -0,0 +1,197 @@ +package com.catacomb.spike + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp + +/** + * Settings section: theme picker (all desktop themes), default quality, engine + * info, and a live demo of the shared Rust core. + */ +@Composable +fun SettingsScreen( + currentThemeId: String, + currentQuality: String, + onThemeSelected: (String) -> Unit, + onQualitySelected: (String) -> Unit, +) { + var quality = currentQuality + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // ── Theme picker ──────────────────────────────────────────────── + SectionCard("Appearance") { + Text( + "Theme", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(bottom = 10.dp), + ) + ThemeSwatchGrid(currentThemeId, onThemeSelected) + } + + // ── Default quality ───────────────────────────────────────────── + SectionCard("Downloads") { + Text( + "Default quality", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(bottom = 10.dp), + ) + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(Quality.entries.size) { idx -> + val q = Quality.entries[idx] + FilterChip( + selected = quality == q.id, + onClick = { + quality = q.id + onQualitySelected(q.id) + }, + label = { Text(q.label) }, + leadingIcon = if (quality == q.id) { + { Icon(Icons.Filled.Check, contentDescription = null, Modifier.size(18.dp)) } + } else null, + ) + } + } + } + + // ── Engine ────────────────────────────────────────────────────── + SectionCard("Engine") { + val s = Engine.state + val line = when (s) { + is EngineState.Ready -> "yt-dlp ${s.version} · bundled Python + ffmpeg" + is EngineState.Initializing -> "Initialising…" + is EngineState.Failed -> "Failed: ${s.message}" + } + Text(line, style = MaterialTheme.typography.bodyMedium) + } + + // ── Rust core demo ────────────────────────────────────────────── + SectionCard("Rust core (JNI)") { + val probe = runCatching { + val p = RustCore.platformFromUrl("https://youtu.be/dQw4w9WgXcQ") + val cls = RustCore.classifyError("ERROR: HTTP Error 429: Too Many Requests") + val cues = RustCore.vttParse("WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nhi\n") + "platformFromUrl → $p\n\nclassifyError → $cls\n\nvttParse → $cues" + }.getOrElse { "Rust core error: ${it.message}" } + Text( + probe, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // ── About ─────────────────────────────────────────────────────── + SectionCard("About") { + Text( + "Catacomb Android — Stage-1 prototype.\n" + + "Bundled on-device yt-dlp engine + shared Rust core over JNI.\n" + + "AGPL-3.0.", + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +/** A wrapping grid of theme swatches; tapping one applies it live. */ +@Composable +private fun ThemeSwatchGrid(currentThemeId: String, onThemeSelected: (String) -> Unit) { + // Simple manual wrap: 3 columns. + val cols = 3 + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + THEMES.chunked(cols).forEach { rowThemes -> + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + rowThemes.forEach { t -> + ThemeSwatch( + theme = t, + selected = t.id == currentThemeId, + onClick = { onThemeSelected(t.id) }, + modifier = Modifier.weight(1f), + ) + } + // pad the last row so weights stay even + repeat(cols - rowThemes.size) { Spacer(Modifier.weight(1f)) } + } + } + } +} + +@Composable +private fun ThemeSwatch( + theme: CatTheme, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val borderColor = + if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline + Column( + modifier + .clip(RoundedCornerShape(12.dp)) + .border(if (selected) 2.dp else 1.dp, borderColor, RoundedCornerShape(12.dp)) + .background(theme.bg) + .clickable(onClick = onClick) + .padding(10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + // Accent dots preview the theme's primary/secondary/tertiary. + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Dot(theme.primary) + Dot(theme.secondary) + Dot(theme.tertiary) + if (selected) { + Spacer(Modifier.width(2.dp)) + Icon( + Icons.Filled.Check, + contentDescription = "selected", + tint = theme.primary, + modifier = Modifier.size(14.dp), + ) + } + } + Text( + theme.label, + style = MaterialTheme.typography.bodySmall, + color = if (theme.dark) Color(0xFFF4F4F8) else Color(0xFF101014), + maxLines = 1, + ) + } +} + +@Composable +private fun Dot(color: Color) { + Box(Modifier.size(14.dp).clip(CircleShape).background(color)) +} diff --git a/android/app/src/main/java/com/catacomb/spike/Theme.kt b/android/app/src/main/java/com/catacomb/spike/Theme.kt new file mode 100644 index 0000000..34ed15e --- /dev/null +++ b/android/app/src/main/java/com/catacomb/spike/Theme.kt @@ -0,0 +1,123 @@ +package com.catacomb.spike + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +/** + * One selectable theme, ported 1:1 from the desktop `src/theme.rs` palettes so + * the Android build offers the same look. `bg`/`surface` come from egui's + * `panel_fill`/`window_fill`; `primary`/`secondary`/`tertiary` are the desktop + * `accents_for()` accent/success/warning colours. + */ +data class CatTheme( + val id: String, + val label: String, + val dark: Boolean, + val bg: Color, + val surface: Color, + val primary: Color, + val secondary: Color, + val tertiary: Color, +) + +private fun c(hex: Long) = Color(0xFF000000 or hex) + +/** All desktop themes, in the same order as `theme.rs::THEMES`. */ +val THEMES: List = listOf( + CatTheme("dark", "Dark", true, c(0x14141a), c(0x1a1a22), c(0x7aa2f7), c(0x9ece6a), c(0xbb9af7)), + CatTheme("light", "Light", false, c(0xf6f4ef), c(0xfefcf7), c(0x2a5db0), c(0x2e7d32), c(0x8e44ad)), + CatTheme("dracula", "Dracula", true, c(0x282a36), c(0x2f3142), c(0xbd93f9), c(0x50fa7b), c(0xff79c6)), + CatTheme("trans", "Trans", false, c(0xe8f7fd), c(0xfef0f4), c(0x2288cc), c(0x2e7d32), c(0xf7a8b8)), + CatTheme("emo-nocturnal", "Emo: Nocturnal", true, c(0x0a0a0a), c(0x0d0d0d), c(0xff0090), c(0x39ff14), c(0x00f5ff)), + CatTheme("emo-coffin", "Emo: Coffin", true, c(0x0d0009), c(0x110010), c(0xb01a1a), c(0x39ff14), c(0xcc2222)), + CatTheme("emo-scene-queen", "Emo: Scene Queen", true, c(0x080818), c(0x0a0a1e), c(0x39ff14), c(0xff00ff), c(0x00f5ff)), + CatTheme("cemetery-moss", "Cemetery Moss", true, c(0x1a1f1a), c(0x1e241e), c(0x7a8a6a), c(0x8faf6a), c(0x9a9a8a)), + CatTheme("vampire", "Vampire", true, c(0x0d0006), c(0x120008), c(0xc9a227), c(0xd11a2b), c(0x8c0a1e)), + CatTheme("witching-hour", "Witching Hour", true, c(0x0a0a1f), c(0x0e0e28), c(0x8a6aab), c(0xb0b8d0), c(0x5a5aaa)), + CatTheme("cyberpunk", "Cyberpunk", true, c(0x0a0a12), c(0x0e0e18), c(0x00fff5), c(0x39ff14), c(0xff003c)), + CatTheme("synthwave", "Synthwave '84", true, c(0x2b0a3d), c(0x330a48), c(0xff2a6d), c(0x05d9e8), c(0xf649c9)), + CatTheme("vaporwave", "Vaporwave", true, c(0x1a0033), c(0x200040), c(0x01cdfe), c(0x05ffa1), c(0xff71ce)), + CatTheme("nord", "Nord", true, c(0x2e3440), c(0x3b4252), c(0x88c0d0), c(0xa3be8c), c(0xebcb8b)), + CatTheme("gruvbox", "Gruvbox", true, c(0x282828), c(0x32302f), c(0xfe8019), c(0xb8bb26), c(0xd3869b)), + CatTheme("tokyo-night", "Tokyo Night", true, c(0x1a1b26), c(0x20202f), c(0x7aa2f7), c(0x9ece6a), c(0xbb9af7)), + CatTheme("paper", "Paper", false, c(0xf4ecd8), c(0xf8f0dc), c(0x8b6f3a), c(0x4a6b3a), c(0xc4a86a)), + CatTheme("honey", "Honey", false, c(0xfff4e0), c(0xfff9ec), c(0xd98a1e), c(0xb87420), c(0xc97b4a)), + CatTheme("candlelight", "Candlelight", false, c(0xf2e6d0), c(0xf6ecd9), c(0xa8703a), c(0x6b3f1e), c(0xc79a5a)), +) + +fun themeById(id: String): CatTheme = THEMES.firstOrNull { it.id == id } ?: THEMES[0] + +/** Pick black/white text for best contrast against [bg]. */ +private fun onColor(bg: Color): Color { + val l = 0.299 * bg.red + 0.587 * bg.green + 0.114 * bg.blue + return if (l > 0.55) Color(0xFF101014) else Color(0xFFF4F4F8) +} + +private fun elevate(base: Color, dark: Boolean): Color { + // A slightly lifted surface variant for cards/inputs. + val f = if (dark) 0.06f else -0.04f + fun ch(v: Float) = (v + f).coerceIn(0f, 1f) + return Color(ch(base.red), ch(base.green), ch(base.blue), 1f) +} + +private val AppType = Typography( + headlineSmall = TextStyle(fontWeight = FontWeight.Bold, fontSize = 22.sp), + titleLarge = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 20.sp), + titleMedium = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 16.sp), + labelLarge = TextStyle(fontWeight = FontWeight.SemiBold, fontSize = 14.sp), +) + +/** Wrap [content] in a Material3 theme built from the selected [theme]. */ +@Composable +fun CatacombTheme(theme: CatTheme, content: @Composable () -> Unit) { + val onBg = onColor(theme.bg) + val onSurface = onColor(theme.surface) + val surfaceVariant = elevate(theme.surface, theme.dark) + val scheme = if (theme.dark) { + darkColorScheme( + primary = theme.primary, + onPrimary = onColor(theme.primary), + secondary = theme.secondary, + onSecondary = onColor(theme.secondary), + tertiary = theme.tertiary, + onTertiary = onColor(theme.tertiary), + background = theme.bg, + onBackground = onBg, + surface = theme.surface, + onSurface = onSurface, + surfaceVariant = surfaceVariant, + onSurfaceVariant = onSurface.copy(alpha = 0.85f), + outline = onSurface.copy(alpha = 0.35f), + ) + } else { + lightColorScheme( + primary = theme.primary, + onPrimary = onColor(theme.primary), + secondary = theme.secondary, + onSecondary = onColor(theme.secondary), + tertiary = theme.tertiary, + onTertiary = onColor(theme.tertiary), + background = theme.bg, + onBackground = onBg, + surface = theme.surface, + onSurface = onSurface, + surfaceVariant = surfaceVariant, + onSurfaceVariant = onSurface.copy(alpha = 0.85f), + outline = onSurface.copy(alpha = 0.35f), + ) + } + MaterialTheme(colorScheme = scheme, typography = AppType, content = content) +} + +// Keep the unused-import guard away from isSystemInDarkTheme if a future +// "system" theme is added; referenced here so the import stays intentional. +@Suppress("unused") +private val darkFollowSystem: @Composable () -> Boolean = { isSystemInDarkTheme() } diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..2d24d79 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/build-apk.sh b/android/build-apk.sh new file mode 100755 index 0000000..0d7c754 --- /dev/null +++ b/android/build-apk.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Build the Catacomb Android app (Compose + bundled yt-dlp + Rust JNI core). +# +# Runs Gradle on a JDK it tolerates (<= 21; the system JDK may be newer and +# breaks R8/d8) and ensures the Rust native libs exist first. Produces a +# debug-signed, installable APK. +# +# Usage: ./build-apk.sh +# Output: app/build/outputs/apk/debug/app-debug.apk +set -euo pipefail +cd "$(dirname "$0")" + +# ── JDK for Gradle/AGP (<= 21) ──────────────────────────────────────────── +find_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; do + [[ -x "/usr/lib/jvm/$c/bin/java" ]] && { echo "/usr/lib/jvm/$c"; return; } + done + echo "${JAVA_HOME:-}" +} +export JAVA_HOME="$(find_jdk)" +[[ -x "$JAVA_HOME/bin/java" ]] || { echo "ERROR: need a JDK <= 21 (set TOOL_JAVA_HOME)."; exit 1; } +echo "Using JDK: $JAVA_HOME" + +# ── Android SDK location for Gradle ─────────────────────────────────────── +SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Android/Sdk}}" +[[ -d "$SDK" ]] || { echo "ERROR: Android SDK not found (set ANDROID_HOME)."; exit 1; } +if [[ ! -f local.properties ]]; then + echo "sdk.dir=$SDK" > local.properties +fi + +# ── Native libs (Rust core) ─────────────────────────────────────────────── +if ! ls app/src/main/jniLibs/*/libcatacomb_core.so >/dev/null 2>&1; then + echo "Building Rust core native libs…" + ( cd rust/catacomb_core && ./build.sh ) +fi + +# ── Debug keystore (stable, reproducible signing) ───────────────────────── +if [[ ! -f debug.keystore ]]; then + "$JAVA_HOME/bin/keytool" -genkeypair -keystore debug.keystore \ + -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 + +echo "── gradlew assembleDebug ─────────────────────────────────────" +./gradlew --no-daemon assembleDebug "$@" + +APK="app/build/outputs/apk/debug/app-debug.apk" +if [[ -f "$APK" ]]; then + echo "" + echo "✓ APK: $(pwd)/$APK ($(du -h "$APK" | cut -f1))" + echo " Install: adb install -r $APK" +else + echo "✗ build finished but $APK not found" >&2; exit 1 +fi diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..ebb767a --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,6 @@ +// Top-level build file. Plugin versions are pinned for a reproducible build +// against Gradle 8.9 on JDK 17 (see build-apk.sh). +plugins { + id("com.android.application") version "8.5.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.24" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..004b153 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,7 @@ +# Portable Gradle settings. The JDK is supplied via JAVA_HOME at invocation +# time (build-apk.sh pins JDK 17) rather than hardcoding a machine path here. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.caching=true +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..2c35211 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..09523c0 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..fefcac9 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,19 @@ +// Root settings for the Catacomb Android app (Compose + Material3). +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +@Suppress("UnstableApiUsage") +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Catacomb" +include(":app")