feat(android): scaffold the native Android client (Rust-heavy JNI bridge)

Rust-heavy client model (like punktfunk-client-linux): a new cdylib crate
crates/punktfunk-android links punktfunk-core and exposes the JNI seam;
Kotlin (clients/android) owns only the Android-framework surface. Kotlin can't
import the C header the way Swift can, so the bridge is written in Rust to reuse
the Linux client's orchestration rather than re-port it.

- crates/punktfunk-android: JNI bridge — abiVersion/coreVersion native-link
  proof + session connect/close handle; plane pumps stubbed for M4 stage 1.
- clients/android: Gradle project — :app (Compose) + :kit (Android library with
  a cargo-ndk Exec task -> jniLibs). AGP 9.2 / Gradle 9.4.1 / Kotlin 2.3.21 /
  Compose BOM 2026.05.01 / compileSdk 37 / targetSdk 36 / minSdk 31, shipping
  arm64-v8a + x86_64. Phone + TV (leanback) installable. README rewritten.
- .gitea/workflows/android.yml: CI mirroring apple.yml on a Linux runner.
- punktfunk-core: switch rcgen to the ring backend so the whole quic tree is
  aws-lc-free (smaller client .so, cmake-free cross-compile; a win for all targets).

Validated on this box: :app:assembleDebug -> APK with both ABIs; emulator
first-light renders the bridge linked (core ABI v2) with logcat confirmation;
clippy -D warnings + cargo fmt clean; core tests green on the ring backend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 01:37:46 +02:00
parent 38149871b1
commit b55b7d0ef0
24 changed files with 1040 additions and 15 deletions
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- punktfunk/1 QUIC/UDP data plane. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- mDNS discovery of _punktfunk._udp on the LAN (NsdManager). -->
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
<!-- Enforced from Android 17 (SDK 37) for ALL local-network traffic incl. the QUIC socket.
Harmless to declare on earlier releases. -->
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<!-- Mic uplink to the host's virtual microphone (requested at runtime). -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Gamepad rumble feedback. -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- We target phone + TV from day one: keep the app installable on TV (no touchscreen) and on
devices without a gamepad. -->
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
<application
android:allowBackup="true"
android:icon="@android:drawable/sym_def_app_icon"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.PunktfunkAndroid">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|density|navigation"
android:theme="@style/Theme.PunktfunkAndroid">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- TV launcher entry. -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -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)
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">punktfunk</string>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- The Activity is pure Compose; this platform theme just provides a no-action-bar host.
Compose draws its own Material 3 surfaces over it. -->
<style name="Theme.PunktfunkAndroid" parent="android:Theme.Material.NoActionBar" />
</resources>