Files
chanora/crates/chanora_bridge/src/android_init.rs
T
EdisonJwa c81ccfd9a9 feat(android): produce v0.2.0-beta.1 Android APK with voice in/out
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.
2026-05-14 23:40:58 +08:00

83 lines
2.8 KiB
Rust

//! Android-specific JNI lifecycle helpers.
//!
//! On Android the Flutter engine starts our cdylib but does not push
//! anything into `ndk_context` (the global the cpal-on-Oboe backend
//! reads to find Android audio services). Without that, the first
//! audio call hangs / fails.
//!
//! We solve this two ways:
//!
//! 1. `JNI_OnLoad` captures the `JavaVM*` as soon as the library is
//! loaded.
//! 2. We expose a `Java_app_chanora_chanora_1flutter_MainActivity_initChanoraContext`
//! JNI function the Kotlin `MainActivity.onCreate` calls with its
//! application Context. That JNI function pushes both the
//! `JavaVM*` and a `Context` global ref into `ndk_context`.
//!
//! After that, cpal can open the default input/output devices.
#![cfg(target_os = "android")]
use std::sync::Once;
use jni::objects::{JClass, JObject};
use jni::sys::{jint, JNI_VERSION_1_6};
use jni::JNIEnv;
use log::{error, info};
/// Stash the JavaVM pointer between JNI_OnLoad and initContext.
static mut JAVA_VM: *mut std::ffi::c_void = std::ptr::null_mut();
static INIT_ONCE: Once = Once::new();
/// JNI entry point invoked by the Android runtime when the cdylib is
/// loaded via `System.loadLibrary` (which `flutter_rust_bridge` does
/// at `RustLib.init`).
#[no_mangle]
pub extern "system" fn JNI_OnLoad(
vm: *mut std::ffi::c_void,
_reserved: *mut std::ffi::c_void,
) -> 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
);
});
}