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.
This commit is contained in:
Generated
+22
-1
@@ -80,6 +80,12 @@ dependencies = [
|
|||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "android_log-sys"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85965b6739a430150bdd138e2374a98af0c3ee0d030b3bb7fc3bddff58d0102e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android_log-sys"
|
name = "android_log-sys"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -92,7 +98,7 @@ version = "0.15.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
|
checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"android_log-sys",
|
"android_log-sys 0.3.2",
|
||||||
"env_filter",
|
"env_filter",
|
||||||
"log",
|
"log",
|
||||||
]
|
]
|
||||||
@@ -328,10 +334,14 @@ dependencies = [
|
|||||||
"chanora_core",
|
"chanora_core",
|
||||||
"chanora_protocol",
|
"chanora_protocol",
|
||||||
"flutter_rust_bridge",
|
"flutter_rust_bridge",
|
||||||
|
"jni 0.21.1",
|
||||||
|
"log",
|
||||||
|
"ndk-context",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"tracing-android",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3121,6 +3131,17 @@ dependencies = [
|
|||||||
"tracing-core",
|
"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]]
|
[[package]]
|
||||||
name = "tracing-attributes"
|
name = "tracing-attributes"
|
||||||
version = "0.1.31"
|
version = "0.1.31"
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- Required for the protocol layer to dial a TeamSpeak-compatible
|
||||||
|
server over UDP. -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<!-- Required for the audio engine (chanora_audio) to open the
|
||||||
|
capture stream for voice transmission. The runtime grant
|
||||||
|
must still be requested; this declaration only allows the
|
||||||
|
app to ask. -->
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="chanora_flutter"
|
android:label="Chanora"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
+29
-1
@@ -1,5 +1,33 @@
|
|||||||
package app.chanora.chanora_flutter
|
package app.chanora.chanora_flutter
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
class MainActivity : FlutterActivity() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
init {
|
||||||
|
// Force-load the chanora_bridge cdylib at activity-class-init
|
||||||
|
// time so JNI_OnLoad captures the JavaVM* before Flutter / FRB
|
||||||
|
// tries to call any Rust function.
|
||||||
|
System.loadLibrary("chanora_bridge")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JNI entry point implemented in `chanora_bridge::android_init`.
|
||||||
|
* Initialises `ndk_context` with our Activity so cpal-on-Oboe can
|
||||||
|
* find Android audio services when `chanora_audio` starts the
|
||||||
|
* capture / playback streams.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
external fun initChanoraContext(context: android.content.Context)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
// Pass the Application context to the Rust side so the audio
|
||||||
|
// engine can open device handles. Must run before any
|
||||||
|
// `chanora_audio` call from Dart.
|
||||||
|
initChanoraContext(applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.0+1
|
version: 0.2.0+2
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.5
|
sdk: ^3.11.5
|
||||||
|
|||||||
@@ -25,5 +25,14 @@ tracing.workspace = true
|
|||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||||
|
|
||||||
|
# Android: route `tracing` output to logcat so a user can see protocol
|
||||||
|
# and audio diagnostics via `adb logcat -s chanora`. Also brings in the
|
||||||
|
# JNI bindings we need to initialise `ndk_context` for cpal-on-Oboe.
|
||||||
|
[target.'cfg(target_os = "android")'.dependencies]
|
||||||
|
tracing-android = "0.2"
|
||||||
|
log = "0.4"
|
||||||
|
jni = { version = "0.21", default-features = false }
|
||||||
|
ndk-context = "0.1"
|
||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] }
|
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] }
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
//! 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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -45,6 +45,24 @@ fn session() -> &'static chanora_core::ChanoraSession {
|
|||||||
#[frb(init)]
|
#[frb(init)]
|
||||||
pub fn bridge_init() {
|
pub fn bridge_init() {
|
||||||
flutter_rust_bridge::setup_default_user_utils();
|
flutter_rust_bridge::setup_default_user_utils();
|
||||||
|
|
||||||
|
// 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()
|
let _ = tracing_subscriber::fmt()
|
||||||
.with_env_filter(
|
.with_env_filter(
|
||||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
@@ -52,6 +70,8 @@ pub fn bridge_init() {
|
|||||||
)
|
)
|
||||||
.with_target(true)
|
.with_target(true)
|
||||||
.try_init();
|
.try_init();
|
||||||
|
}
|
||||||
|
|
||||||
info!(target: "chanora_bridge", "bridge initialised");
|
info!(target: "chanora_bridge", "bridge initialised");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,9 @@
|
|||||||
pub mod api;
|
pub mod api;
|
||||||
mod frb_generated;
|
mod frb_generated;
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
mod android_init;
|
||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
/// Errors raised at the bridge boundary. Production code must keep
|
/// Errors raised at the bridge boundary. Production code must keep
|
||||||
|
|||||||
Executable
+28
@@ -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" "$@"
|
||||||
Reference in New Issue
Block a user