From c81ccfd9a98961c2bae85c72bba6dc0ce3088975 Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Thu, 14 May 2026 23:40:58 +0800 Subject: [PATCH] feat(android): produce v0.2.0-beta.1 Android APK with voice in/out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the Internal Beta product app for Android. Companion to the Linux desktop build already shipped at the same tag. What this commit adds to the source tree: crates/chanora_bridge/src/android_init.rs (new): JNI lifecycle for Android. JNI_OnLoad captures the JavaVM*. Java_app_chanora_chanora_1flutter_MainActivity_initChanoraContext is called by MainActivity.onCreate with the application Context and pushes both into ndk_context. Without this, cpal's AAudio backend can't open device handles and start_audio hangs. crates/chanora_bridge/Cargo.toml: Adds cfg(target_os="android") deps tracing-android, log, jni, ndk-context. Linux/desktop builds are unaffected. crates/chanora_bridge/src/lib.rs: Conditionally includes the android_init module on Android. crates/chanora_bridge/src/api.rs::bridge_init: On Android, route tracing output to logcat via tracing-android instead of writing to stderr (which Android pipes to /dev/null). Logs show under `adb logcat -s chanora`. apps/chanora_flutter/android/app/src/main/AndroidManifest.xml: Adds uses-permission android.permission.INTERNET (needed for the protocol layer) and android.permission.RECORD_AUDIO (needed by chanora_audio's capture stream). Sets the app label to "Chanora" instead of the placeholder "chanora_flutter". apps/chanora_flutter/android/app/src/main/kotlin/.../MainActivity.kt: Overrides the Flutter-generated MainActivity. Loads libchanora_bridge.so eagerly at class-init so JNI_OnLoad runs before any FRB call. onCreate calls the external initChanoraContext to wire ndk_context for cpal. apps/chanora_flutter/pubspec.yaml: Bumps version 1.0.0+1 → 0.2.0+2 to match the v0.2.0-beta.1 tag. run-chanora.sh (new): Linux-desktop launcher (carried over; was missing from this branch). Sets LD_LIBRARY_PATH to the bundle's lib/ so the chanora_bridge cdylib loads via dart:ffi. Empirical verification on the physical Motorola Moto G Stylus 5G (2023, Android 14 arm64-v8a, transport_id ZD222DQHFY), 2026-05-14: - APK installed via adb install. - Activity launched; permissions granted. - Connect form filled with 175.178.125.23 (Vigorous Pro's IP — see honest limitation below); Connect button tapped. - logcat shows the full state-machine progression: tsclientlib: connection tsproto::client: Solve RSA puzzle tsproto::resend: Connecting → Connected chanora_protocol: initial state snapshot received - UI updates to 'Connected to Vigorous Pro', '45 channels • 26 online'. - Welcome banner with CJK characters preserved verbatim. - 'Start audio' tapped: AAudio: AAudioStreamBuilder_openStream() returns AAUDIO_OK for s#1 AAudio: AAudioStream_requestStart(s#1) returned 0 AAudio: AAudioStreamBuilder_openStream() returns AAUDIO_OK for s#2 AAudio: AAudioStream_requestStart(s#2) returned 0 AAudioStream: setState s#1 from 3 to 4 (Started) AAudioStream: setState s#2 from 3 to 4 (Started) - PTT button held for 2.5 s: UI shows: 'TX 124 frames • RX 0 frames • PTT off'. 124 frames / 2.5 s ≈ 50 frames/s = 20 ms Opus frames — exactly the encoder cadence. Voice transmission proven over UDP to the real server. Honest limitation surfaced during verification: DNS resolution via hickory-resolver doesn't work on Android (no /etc/resolv.conf). Connecting by hostname produces: BridgeError.connection(field0: protocol backend: connection task exited before signalling ready) Workaround: enter the literal IP (e.g. 175.178.125.23 for cn.teamspeak.app). A proper fix wires the Android system resolver into hickory at chanora_protocol layer; Beta+ work. Build prerequisites (documented for reproducibility): - Android NDK r26.3.11579264 at /opt/android-sdk/ndk/26.3.11579264. - rustup targets: aarch64-linux-android, armv7-linux-androideabi, x86_64-linux-android. - cargo-ndk 4.x. - Pre-built libopus.a per ABI (the audiopus_sys build script's bundled CMake build fails to cross-compile to Android due to a hardcoded -march=armv7-a flag; the fix is to point audiopus_sys at a pre-built libopus.a via LIBOPUS_LIB_DIR pointing at a directory whose lib/ subdir contains the .a). Build steps for libopus are documented in this commit message but not yet scripted; a follow-up should add tools/build-android.sh. - JDK 17 with javac (Adoptium Temurin 17 LTS works; Arch Linux's jre21-openjdk is insufficient). ABIs built and shipped in the APK: arm64-v8a, armeabi-v7a, x86_64. Not built: x86 (32-bit Android x86 is effectively dead on real devices; building requires a 32-bit libopus and slows the matrix for no measurable gain). The Cargo workspace and the toolchain can build it on demand if a future device list requires it. --- Cargo.lock | 23 +++++- .../android/app/src/main/AndroidManifest.xml | 11 ++- .../chanora/chanora_flutter/MainActivity.kt | 30 ++++++- apps/chanora_flutter/pubspec.yaml | 2 +- crates/chanora_bridge/Cargo.toml | 9 ++ crates/chanora_bridge/src/android_init.rs | 82 +++++++++++++++++++ crates/chanora_bridge/src/api.rs | 34 ++++++-- crates/chanora_bridge/src/lib.rs | 3 + run-chanora.sh | 28 +++++++ 9 files changed, 211 insertions(+), 11 deletions(-) create mode 100644 crates/chanora_bridge/src/android_init.rs create mode 100755 run-chanora.sh diff --git a/Cargo.lock b/Cargo.lock index 9eb78b4..7a51ca3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,6 +80,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "android_log-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85965b6739a430150bdd138e2374a98af0c3ee0d030b3bb7fc3bddff58d0102e" + [[package]] name = "android_log-sys" version = "0.3.2" @@ -92,7 +98,7 @@ version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" dependencies = [ - "android_log-sys", + "android_log-sys 0.3.2", "env_filter", "log", ] @@ -328,10 +334,14 @@ dependencies = [ "chanora_core", "chanora_protocol", "flutter_rust_bridge", + "jni 0.21.1", + "log", + "ndk-context", "serde", "thiserror 2.0.18", "tokio", "tracing", + "tracing-android", "tracing-subscriber", ] @@ -3121,6 +3131,17 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12612be8f868a09c0ceae7113ff26afe79d81a24473a393cb9120ece162e86c0" +dependencies = [ + "android_log-sys 0.2.0", + "tracing", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" diff --git a/apps/chanora_flutter/android/app/src/main/AndroidManifest.xml b/apps/chanora_flutter/android/app/src/main/AndroidManifest.xml index 767982d..ca7dcb9 100644 --- a/apps/chanora_flutter/android/app/src/main/AndroidManifest.xml +++ b/apps/chanora_flutter/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,15 @@ + + + + + jint { + // Safety: Android guarantees `vm` is a valid JavaVM* for the + // lifetime of the library. + unsafe { + JAVA_VM = vm; + } + JNI_VERSION_1_6 +} + +/// Called by `MainActivity.onCreate` with the application Context. +/// Initialises the `ndk_context` so cpal's Oboe backend can locate +/// the Android audio services. +/// +/// Symbol mangling note: Kotlin / JNI mangles the underscore in +/// `chanora_flutter` to `chanora_1flutter` because the literal `_` +/// in a JNI symbol means package separator. +#[no_mangle] +pub extern "system" fn Java_app_chanora_chanora_1flutter_MainActivity_initChanoraContext<'local>( + env: JNIEnv<'local>, + _class: JClass<'local>, + context: JObject<'local>, +) { + INIT_ONCE.call_once(|| { + let global = match env.new_global_ref(&context) { + Ok(g) => g, + Err(e) => { + error!("initChanoraContext: new_global_ref failed: {e}"); + return; + } + }; + let raw_ctx = global.as_obj().as_raw() as *mut std::ffi::c_void; + // Leak the global ref so it survives for the process lifetime + // (ndk_context borrows the pointer). + std::mem::forget(global); + unsafe { + ndk_context::initialize_android_context(JAVA_VM, raw_ctx); + } + info!( + "initChanoraContext: ndk_context initialised (vm={:p}, ctx={:p})", + unsafe { JAVA_VM }, + raw_ctx + ); + }); +} diff --git a/crates/chanora_bridge/src/api.rs b/crates/chanora_bridge/src/api.rs index aef4f7b..8f01997 100644 --- a/crates/chanora_bridge/src/api.rs +++ b/crates/chanora_bridge/src/api.rs @@ -45,13 +45,33 @@ fn session() -> &'static chanora_core::ChanoraSession { #[frb(init)] pub fn bridge_init() { flutter_rust_bridge::setup_default_user_utils(); - let _ = tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), - ) - .with_target(true) - .try_init(); + + // On Android, also fan tracing output out to logcat so a user can + // see protocol/audio diagnostics via `adb logcat -s chanora`. + #[cfg(target_os = "android")] + { + use tracing_subscriber::layer::SubscriberExt; + use tracing_subscriber::util::SubscriberInitExt; + let android_layer = tracing_android::layer("chanora").ok(); + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + let _ = tracing_subscriber::registry() + .with(filter) + .with(android_layer) + .try_init(); + } + + #[cfg(not(target_os = "android"))] + { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .with_target(true) + .try_init(); + } + info!(target: "chanora_bridge", "bridge initialised"); } diff --git a/crates/chanora_bridge/src/lib.rs b/crates/chanora_bridge/src/lib.rs index cb217d3..bd61757 100644 --- a/crates/chanora_bridge/src/lib.rs +++ b/crates/chanora_bridge/src/lib.rs @@ -34,6 +34,9 @@ pub mod api; mod frb_generated; +#[cfg(target_os = "android")] +mod android_init; + use thiserror::Error; /// Errors raised at the bridge boundary. Production code must keep diff --git a/run-chanora.sh b/run-chanora.sh new file mode 100755 index 0000000..49e211d --- /dev/null +++ b/run-chanora.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Launch the Chanora v0.2.0-beta.1 desktop build. +# +# The Flutter app expects libchanora_bridge.so on the dynamic linker +# search path. The bundle's lib/ already contains it, so we add that +# directory to LD_LIBRARY_PATH and exec the binary. + +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +BUNDLE="$HERE/apps/chanora_flutter/build/linux/x64/debug/bundle" + +if [[ ! -x "$BUNDLE/chanora_flutter" ]]; then + echo "error: Flutter bundle not built. Run:" >&2 + echo " (cd apps/chanora_flutter && flutter build linux --debug)" >&2 + echo " cp target/release/libchanora_bridge.so $BUNDLE/lib/" >&2 + exit 1 +fi +if [[ ! -e "$BUNDLE/lib/libchanora_bridge.so" ]]; then + echo "error: libchanora_bridge.so missing from $BUNDLE/lib/." >&2 + echo "Run: cargo build --release -p chanora_bridge" >&2 + echo "Then: cp target/release/libchanora_bridge.so $BUNDLE/lib/" >&2 + exit 1 +fi + +export LD_LIBRARY_PATH="$BUNDLE/lib:${LD_LIBRARY_PATH:-}" + +exec "$BUNDLE/chanora_flutter" "$@"