diff --git a/.gitea/workflows/android.yml b/.gitea/workflows/android.yml new file mode 100644 index 0000000..7214851 --- /dev/null +++ b/.gitea/workflows/android.yml @@ -0,0 +1,64 @@ +# Android client CI (Gitea Actions). Builds the Rust JNI core (crates/punktfunk-android) via +# cargo-ndk for both shipping ABIs and assembles the debug APK (clients/android). Mirrors apple.yml +# but on a Linux runner — the NDK is cross-platform, so no self-hosted host is needed. +# +# Prereq: the runner needs ~6 GB free + internet (it pulls the Android SDK/NDK and the Gradle +# distribution in-job). If android-actions/setup-android is not mirrored on this Gitea instance, +# replace that step with a manual cmdline-tools download, or bake an `android-ci` image like +# ci/rust-ci.Dockerfile. Emulator instrumentation tests are deferred until a KVM-capable runner +# exists (they self-skip otherwise, like apple.yml's RemoteFirstLightTests). +name: android + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + android: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - name: JDK 21 (AGP 9.2 runs on JDK 17–21, not the host default) + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Rust toolchain + Android targets (self-healing on a fresh runner) + run: | + if ! command -v rustup >/dev/null && [ ! -x "$HOME/.cargo/bin/rustup" ]; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --no-modify-path --profile minimal + fi + RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")" + dirname "$RUSTUP" >> "$GITHUB_PATH" + "$RUSTUP" target add aarch64-linux-android x86_64-linux-android + + - name: Android SDK + uses: android-actions/setup-android@v3 + + - name: NDK r28 LTS + platform 36 + build-tools + run: sdkmanager "platform-tools" "platforms;android-36" "build-tools;36.0.0" "ndk;28.2.13676358" + + - name: Caches (cargo + gradle) + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + ~/.gradle/caches + ~/.gradle/wrapper + target + key: android-${{ hashFiles('Cargo.lock', 'clients/android/**/*.gradle.kts') }} + restore-keys: android- + + - name: cargo-ndk + run: command -v cargo-ndk >/dev/null || cargo install cargo-ndk + + - name: assembleDebug (cargo-ndk → jniLibs → APK) + working-directory: clients/android + run: ./gradlew :app:assembleDebug --stacktrace diff --git a/Cargo.lock b/Cargo.lock index fa2de06..2592e7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,6 +46,23 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b07e8e73d720a1f2e4b6014766e6039fd2e96a4fa44e2a78d0e1fa2ff49826" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + [[package]] name = "anes" version = "0.1.6" @@ -917,6 +934,16 @@ dependencies = [ "syn", ] +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -2411,6 +2438,16 @@ dependencies = [ "unarray", ] +[[package]] +name = "punktfunk-android" +version = "0.0.1" +dependencies = [ + "android_logger", + "jni", + "log", + "punktfunk-core", +] + [[package]] name = "punktfunk-client-linux" version = "0.0.1" @@ -2715,6 +2752,7 @@ checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" dependencies = [ "aws-lc-rs", "pem", + "ring", "rustls-pki-types", "time", "yasna", diff --git a/Cargo.toml b/Cargo.toml index 6d563dd..112a196 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/punktfunk-host", "crates/punktfunk-client-rs", "crates/punktfunk-client-linux", + "crates/punktfunk-android", "tools/latency-probe", "tools/loss-harness", ] diff --git a/clients/android/.gitignore b/clients/android/.gitignore new file mode 100644 index 0000000..d4ef852 --- /dev/null +++ b/clients/android/.gitignore @@ -0,0 +1,11 @@ +# Gradle / Android build artifacts +.gradle/ +build/ +local.properties +*.iml +.idea/ +captures/ +.cxx/ + +# Native libraries produced by cargo-ndk — regenerated by the :kit cargoNdk* tasks. +**/src/main/jniLibs/ diff --git a/clients/android/README.md b/clients/android/README.md index ec6a31b..77fb8be 100644 --- a/clients/android/README.md +++ b/clients/android/README.md @@ -1,20 +1,69 @@ -# punktfunk Android client (later) +# punktfunk Android client -Kotlin UI + MediaCodec (decode) + a thin JNI layer over the `punktfunk-core` C ABI. +Native Android client for **punktfunk/1**, targeting **phone + TV** (Compose, D-pad + touch). -## Wiring +## Architecture — Rust-heavy (like the Linux client, not thin-native like Apple) -1. Build the core as a shared library per Android ABI: - ```sh - rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android - cargo build -p punktfunk-core --release --target aarch64-linux-android # libpunktfunk_core.so - ``` - (Use `cargo-ndk` to handle the NDK toolchain/linker.) -2. JNI shim: small C/Rust glue mapping `punktfunk_*` to Kotlin `external fun`s, bundling - `libpunktfunk_core.so` into the APK's `jniLibs/`. -3. Kotlin: client `PunktfunkSession` → `punktfunk_client_poll_frame` on a decode thread → feed - `MediaCodec` → render to a `SurfaceView` aligned to the display refresh. +Kotlin cannot `import` the cbindgen C header the way Swift can, so a native bridge is unavoidable. +We write it in **Rust** and link `punktfunk-core` directly — so the Android client reuses the Linux +client's orchestration (audio jitter ring, VK keymap inverse, latency/skew math, capture state +machine, trust logic) instead of re-porting it into Kotlin. + +| Side | Owns | +|------|------| +| **Rust** (`crates/punktfunk-android` → `libpunktfunk_android.so`) | the JNI seam, `NativeClient` (QUIC control + UDP data plane), AnnexB→`AMediaCodec` decode, Opus+Oboe audio, VK keymap, latency math, trust/pairing | +| **Kotlin** (`clients/android`) | Compose UI (host grid / settings / stream), `SurfaceView` lifecycle, input capture, `NsdManager` discovery, Keystore identity, permissions | + +The single seam is `io.unom.punktfunk.kit.NativeBridge` ⇄ `Java_io_unom_punktfunk_kit_NativeBridge_*`. + +## Layout + +``` +crates/punktfunk-android/ Rust cdylib (workspace member) + src/lib.rs JNI_OnLoad + abiVersion/coreVersion (native-link proof) + src/session.rs session handle lifecycle (connect/close); plane pumps = TODO + +clients/android/ Gradle project (this dir) + settings.gradle.kts · build.gradle.kts · gradle.properties · gradlew + app/ :app — Compose application (MainActivity) + kit/ :kit — Android library: NativeBridge + the cargo-ndk build + build.gradle.kts cargoNdk{Debug,Release} → src/main/jniLibs//*.so +``` + +## Prerequisites (already set up on the dev Mac) + +- Android SDK + **NDK r28 LTS** (`28.2.13676358`), `platforms;android-37.0`, `build-tools;37.0.0` +- **JDK 21** for Gradle/AGP (the machine default JDK 25 is too new for AGP 9.2) +- Rust + `rustup target add aarch64-linux-android x86_64-linux-android` + `cargo install cargo-ndk` + +Toolchain pinned: AGP 9.2.0 · Gradle 9.4.1 · Kotlin 2.3.21 · Compose BOM 2026.05.01 · +compileSdk 37 · targetSdk 36 · minSdk 31 · ABIs arm64-v8a + x86_64. + +## Build & run + +**Android Studio:** open `clients/android` — it uses its bundled JBR 21 automatically. The +`cargoNdk*` task builds the `.so` as part of the normal build. + +**CLI** (the machine default is JDK 25, so point Gradle at JDK 21): + +```sh +export JAVA_HOME="$(brew --prefix openjdk@21)/libexec/openjdk.jdk/Contents/Home" +cd clients/android +./gradlew :app:assembleDebug # cargo-ndk cross-compiles libpunktfunk_android.so first +./gradlew :app:installDebug # onto a running emulator/device + +# Emulators (created during env setup): emulator -avd pf_phone | emulator -avd pf_tv +``` + +The debug APK lands in `app/build/outputs/apk/debug/`. The scaffold screen calls +`NativeBridge.abiVersion()` across JNI — a live ABI version proves the whole native stack is wired. ## Status -Placeholder — scheduled after the Apple client (M5). +- **Scaffold (done):** Gradle modules, cargo-ndk wiring, JNI native-link proof, phone+TV-installable + manifest. `crates/punktfunk-core` `rcgen` switched to the `ring` backend so the client `.so` is + aws-lc-free. +- **Next (M4 Android stage 1):** video decode (`AMediaCodec` async → `SurfaceView`), audio + (Opus + Oboe + jitter ring), input capture → `send_input`, pairing/identity (Keystore-wrapped), + mDNS discovery, the phone/TV Compose UI. The Rust-side homes are stubbed in + `crates/punktfunk-android/src/session.rs` with port pointers to `crates/punktfunk-client-linux`. diff --git a/clients/android/app/build.gradle.kts b/clients/android/app/build.gradle.kts new file mode 100644 index 0000000..962ee36 --- /dev/null +++ b/clients/android/app/build.gradle.kts @@ -0,0 +1,67 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("com.android.application") + // AGP 9 built-in Kotlin: NO org.jetbrains.kotlin.android. The Compose compiler plugin is + // supplied by AGP, so it's applied without a version. + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "io.unom.punktfunk" + compileSdk = 37 // Android 17 — required by androidx.core 1.19.0; targetSdk stays 36 for now. + + defaultConfig { + applicationId = "io.unom.punktfunk" + minSdk = 31 + targetSdk = 36 + versionCode = 1 + versionName = "0.0.1" + ndk { abiFilters += listOf("arm64-v8a", "x86_64") } + } + + buildTypes { + release { + isMinifyEnabled = false // scaffold; enable R8 + shrinkResources later + } + } + + buildFeatures { compose = true } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + packaging { + jniLibs { + useLegacyPackaging = false + // punktfunk-core is statically linked into libpunktfunk_android.so (rlib). Its standalone + // cdylib (built because the core crate also declares crate-type = cdylib) is never loaded + // by Kotlin — drop it from the APK rather than ship ~5–9 MB of dead code. + excludes += "**/libpunktfunk_core.so" + } + } +} + +kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } } + +dependencies { + implementation(project(":kit")) + + val composeBom = platform("androidx.compose:compose-bom:2026.05.01") + implementation(composeBom) + + implementation("androidx.core:core-ktx:1.19.0") + implementation("androidx.activity:activity-compose:1.13.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0") + + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.foundation:foundation") + implementation("androidx.compose.material3:material3") + debugImplementation("androidx.compose.ui:ui-tooling") + + // Android TV components (we target phone + TV) land in the TV-UI milestone: + // implementation("androidx.tv:tv-material:1.1.0") + // The manifest already declares leanback so the scaffold installs on TV. +} diff --git a/clients/android/app/src/main/AndroidManifest.xml b/clients/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..72d4f8a --- /dev/null +++ b/clients/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt new file mode 100644 index 0000000..3f3ad89 --- /dev/null +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt @@ -0,0 +1,59 @@ +package io.unom.punktfunk + +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import io.unom.punktfunk.kit.NativeBridge + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Cross the JNI bridge into libpunktfunk_android.so → punktfunk-core. A live ABI version is + // the scaffold's proof the whole native stack is wired (cargo-ndk → jniLibs → APK → + // System.loadLibrary → JNI → core). Logged so it's verifiable headlessly via logcat. + val abi = runCatching { NativeBridge.abiVersion() }.getOrDefault(-1) + val core = runCatching { NativeBridge.coreVersion() }.getOrDefault("?") + Log.i("punktfunk", "native bridge: core ABI v$abi, core $core") + + enableEdgeToEdge() + setContent { + MaterialTheme(colorScheme = darkColorScheme()) { + Surface(modifier = Modifier.fillMaxSize()) { + ScaffoldScreen(abi, core) + } + } + } + } +} + +@Composable +private fun ScaffoldScreen(abi: Int, core: String) { + Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text("punktfunk", style = MaterialTheme.typography.headlineMedium) + Text("Android client — scaffold", style = MaterialTheme.typography.bodyMedium) + Text( + if (abi > 0) "✓ native bridge linked" else "✗ native bridge FAILED", + style = MaterialTheme.typography.titleMedium, + ) + Text("core ABI v$abi · core $core", style = MaterialTheme.typography.bodySmall) + } +} diff --git a/clients/android/app/src/main/res/values/strings.xml b/clients/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..c590999 --- /dev/null +++ b/clients/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + punktfunk + diff --git a/clients/android/app/src/main/res/values/themes.xml b/clients/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..3555c35 --- /dev/null +++ b/clients/android/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + +