feat(beta): External Beta — passwords, channel join, mute, bookmarks, encrypted identity

The v0.3 client could only ever connect to a hardcoded default
channel with no password and offered no controls mid-call.
External Beta closes those gaps and tightens identity-at-rest.

User-facing additions
---------------------

* **Server password** on the connect form. Plumbed through
  `BridgeError`-aware `connect(host, nickname, password)`. Empty
  string means "no password" — no behaviour change for open
  servers.
* **Channel join**: tapping a row (or its login icon) in the
  channel tree issues a `client_move`. Names containing "🔒" or
  "password" prompt for a channel password first.
* **Self-mute** for both microphone (`client_input_muted`) and
  speaker (`client_output_muted`) via FilterChips. Output mute
  also flips the audio engine's local output-muted flag so
  playback silences immediately, before the server acknowledges.
* **Master output gain** slider (0–200%). Plumbed through an
  `AtomicU32` (f32 bits) on the engine that the cpal output
  callback multiplies into every sample.
* **Bookmarks**: SQLite-backed list with Save / Connect / Delete
  actions. Bookmarks persist across app restarts; tapping one
  pre-fills the form and dials immediately.

Hardening
---------

* **Encrypted identity at rest** (RISK-PoC-002 closure for the
  file-only threat model). ChaCha20-Poly1305 envelope: nonce +
  ciphertext written atomically with mode 0600; 32-byte DEK in a
  separate `identity.dek` file. Legacy plaintext identity files
  are auto-detected, read, and upgraded on the next save. Full OS-
  keyring integration is still v0.4 work — documented in the
  store's doc comment.
* **Mobile voice-comm routing**: on Android, `AudioEngine::start`
  uses JNI to set `AudioManager.setMode(MODE_IN_COMMUNICATION)`
  when `cfg.mobile_voice_preset` is true (default). This engages
  the device-side AEC/NS pipeline on most Pixel/Moto/Samsung
  hardware even though cpal still opens the AAudio default input
  preset. Full `setInputPreset(VOICE_COMMUNICATION)` switch is
  still RISK-AUDIO-MOBILE-001 (needs cpal upstream or an Oboe
  fork).
* **Log noise**: bridge default `EnvFilter` now silences
  `tsproto::resend=error` and `tsproto::packet_codec=error` so
  the redacted diagnostic export is human-readable. Still
  overridable via `RUST_LOG=...`.

Engineering
-----------

* **`chanora_storage`** gains `BookmarkRepository` (rusqlite
  bundled) with `add` / `update` / `delete` / `list`. The
  identity store now layers on `chacha20poly1305` + `rand` +
  `zeroize` for the envelope.
* **`chanora_protocol`** exposes `move_to_channel` and
  `set_muted` on `ProtocolClient`, dispatched through the
  existing `connection_task` request channel onto tsclientlib's
  generated `client.client_move(...)` and
  `state.client_update().set_input_muted/set_output_muted(...)`
  paths.
* **`chanora_core::ChanoraSession`** wires the bookmark store
  next to the identity store inside `init_storage`, and adds
  `list_bookmarks` / `add_bookmark` / `update_bookmark` /
  `delete_bookmark` / `move_to_channel` / `set_self_muted` /
  `set_output_gain`.
* **`chanora_audio::AudioEngine`** carries `output_gain` and
  `output_muted` atomics; the output callback consults both. The
  Android branch of `start()` engages MODE_IN_COMMUNICATION via
  a small JNI helper that reuses the `ndk_context` global set by
  the bridge's `android_init` hook.
* **`chanora_bridge::api`** adds `set_input_muted`,
  `set_output_muted`, `set_output_gain`, `move_to_channel`,
  `list_bookmarks`, `add_bookmark`, `update_bookmark`,
  `delete_bookmark`, and the `BridgeBookmark` DTO. FRB v2.12
  codegen regenerated.

Tests + CI
----------

* `chanora_storage` test count rises from 3 to 8 — bookmark CRUD
  round-trip, missing-row → `NotFound`, encrypted round-trip
  (verifies ciphertext is not the plaintext on disk), and the
  legacy plaintext upgrade path.
* New `.github/workflows/ci.yml`: `cargo check --workspace`,
  `cargo test --workspace --no-fail-fast`, `cargo clippy`
  (advisory), `flutter analyze`, and `flutter test` excluding
  the live-server `e2e` tag.

Live-verified on Moto G Stylus 5G against cn.teamspeak.app:
saved a bookmark, reconnected via it, joined a non-default
channel via tap, toggled both mutes, slid the volume, and the
redacted diagnostic export confirmed `AudioManager mode set to
MODE_IN_COMMUNICATION`, `client_move sent`, and `client_update
sent` lines.
This commit is contained in:
EdisonJwa
2026-05-15 01:59:27 +08:00
parent fd181c014c
commit 780fd7eca2
22 changed files with 2638 additions and 134 deletions
+7
View File
@@ -26,3 +26,10 @@ audiopus = "0.3.0-rc.0"
tsclientlib = { git = "https://github.com/ReSpeak/tsclientlib.git", rev = "04aa2491", default-features = false, features = ["default-tls", "audio"] }
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
[target.'cfg(target_os = "android")'.dependencies]
# JNI bindings to flip Android's AudioManager into MODE_IN_COMMUNICATION
# when the voice-comm preset is requested. ndk_context is initialised
# by the bridge crate's android_init shim.
jni = { version = "0.21", default-features = false }
ndk-context = "0.1"
+127 -10
View File
@@ -80,6 +80,15 @@ pub struct AudioEngine {
ptt: Arc<AtomicBool>,
frames_sent: Arc<AtomicU32>,
frames_received: Arc<AtomicU32>,
/// Master output gain as f32 bits in an AtomicU32. Default 1.0.
/// Adjusted via [`Self::set_output_gain`] from the bridge.
output_gain: Arc<AtomicU32>,
/// Master output mute. When true the output callback fills the
/// device buffer with silence regardless of incoming voice
/// frames. Used for self-output-mute on the local device,
/// independent of the server-side mute the protocol layer
/// broadcasts.
output_muted: Arc<AtomicBool>,
// Streams must be dropped to stop audio. Both are `!Send` because
// cpal's Stream isn't Send on some backends; we keep them in an
@@ -141,17 +150,24 @@ impl AudioEngine {
#[cfg(target_os = "android")]
{
if cfg.mobile_voice_preset {
info!(
target: "chanora_audio",
"android: mobile_voice_preset requested (RISK-AUDIO-MOBILE-001 — flag plumbed, switch pending cpal upstream)"
);
match android_engage_voice_communication() {
Ok(()) => info!(
target: "chanora_audio",
"android: AudioManager mode set to MODE_IN_COMMUNICATION"
),
Err(e) => warn!(
target: "chanora_audio",
error = %e,
"android: failed to set MODE_IN_COMMUNICATION; falling back to default routing"
),
}
}
if cfg.effects.aec || cfg.effects.noise_suppression {
info!(
target: "chanora_audio",
aec = cfg.effects.aec,
ns = cfg.effects.noise_suppression,
"android: effects requested; awaiting OS-source switch to engage hardware AEC/NS"
"android: effects requested; engagement depends on device AEC/NS support under MODE_IN_COMMUNICATION"
);
}
}
@@ -168,6 +184,8 @@ impl AudioEngine {
let ptt = Arc::new(AtomicBool::new(cfg.ptt_initial));
let frames_sent = Arc::new(AtomicU32::new(0));
let frames_received = Arc::new(AtomicU32::new(0));
let output_gain = Arc::new(AtomicU32::new(1.0_f32.to_bits()));
let output_muted = Arc::new(AtomicBool::new(false));
// ---------- Capture ----------
// Capture is best-effort. If the platform default input
@@ -218,16 +236,22 @@ impl AudioEngine {
&out_dev,
&out_stream_cfg,
audio_handler.clone(),
output_gain.clone(),
output_muted.clone(),
)?,
SampleFormat::I16 => build_output_stream::<i16>(
&out_dev,
&out_stream_cfg,
audio_handler.clone(),
output_gain.clone(),
output_muted.clone(),
)?,
SampleFormat::U16 => build_output_stream::<u16>(
&out_dev,
&out_stream_cfg,
audio_handler.clone(),
output_gain.clone(),
output_muted.clone(),
)?,
other => {
return Err(AudioError::StreamConfig(format!(
@@ -272,6 +296,8 @@ impl AudioEngine {
ptt,
frames_sent,
frames_received,
output_gain,
output_muted,
_input_stream: Mutex::new(input_stream),
_output_stream: Mutex::new(Some(output_stream)),
shutdown_tx: Some(shutdown_tx),
@@ -316,6 +342,31 @@ impl AudioEngine {
pub fn frames_received(&self) -> u32 {
self.frames_received.load(Ordering::Relaxed)
}
/// Set master output mute. When true the output stream emits
/// silence regardless of incoming voice frames.
pub fn set_output_muted(&self, muted: bool) {
self.output_muted.store(muted, Ordering::Relaxed);
}
/// True if the master output is currently muted locally.
pub fn output_muted(&self) -> bool {
self.output_muted.load(Ordering::Relaxed)
}
/// Set master output gain. 1.0 is unity; 0.0 is silent. Values
/// above 1.0 amplify (and may clip downstream). Clamped to a
/// sensible range internally.
pub fn set_output_gain(&self, gain: f32) {
let clamped = gain.clamp(0.0, 4.0);
self.output_gain
.store(clamped.to_bits(), Ordering::Relaxed);
}
/// Current master output gain.
pub fn output_gain(&self) -> f32 {
f32::from_bits(self.output_gain.load(Ordering::Relaxed))
}
}
impl Drop for AudioEngine {
@@ -533,25 +584,39 @@ fn build_output_stream<T>(
device: &cpal::Device,
config: &cpal::StreamConfig,
handler: Arc<Mutex<AudioHandler<SessionAudioId>>>,
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
) -> Result<cpal::Stream, AudioError>
where
T: SizedSample + FromF32 + Send + 'static,
{
// Reusable f32 scratch buffer. cpal callbacks ask for a max
// buffer size known at construction time; we allocate per-call
// because reusing across calls would need an Arc<Mutex<_>> and
// we already hold one for the handler.
let stream = device
.build_output_stream(
config,
move |out: &mut [T], _| {
let muted = output_muted.load(Ordering::Relaxed);
if muted {
// Still call fill_buffer to keep the jitter
// buffer draining; just discard the result and
// emit silence to the device.
let mut scratch = vec![0.0f32; out.len()];
{
let mut h = handler.lock().unwrap();
h.fill_buffer(&mut scratch);
}
for dst in out.iter_mut() {
*dst = T::from_f32_sample(0.0);
}
return;
}
let gain = f32::from_bits(output_gain.load(Ordering::Relaxed));
let mut scratch = vec![0.0f32; out.len()];
{
let mut h = handler.lock().unwrap();
h.fill_buffer(&mut scratch);
}
for (dst, src) in out.iter_mut().zip(scratch.into_iter()) {
*dst = T::from_f32_sample(src);
*dst = T::from_f32_sample(src * gain);
}
},
move |e| {
@@ -582,3 +647,55 @@ impl FromF32 for u16 {
(s + i32::from(i16::MAX) + 1) as u16
}
}
// ---------- Android voice-communication routing ----------
//
// Engages `AudioManager.MODE_IN_COMMUNICATION` on the Android-side
// AudioManager. This is the routing-level lever that tells the OS
// "this is a voice call, please use the earpiece / engage hardware
// AEC / NS / AGC where the device supports it". cpal opens its
// input stream at the AAudio default preset; on most Android
// devices this honours the global mode and chooses the right
// pipeline. Fully wiring `setInputPreset(VOICE_COMMUNICATION)` would
// need either a cpal fork or a parallel Oboe input — out of scope
// for External Beta.
#[cfg(target_os = "android")]
fn android_engage_voice_communication() -> Result<(), String> {
use jni::objects::{JObject, JString, JValue};
let ctx = ndk_context::android_context();
let vm_ptr = ctx.vm();
if vm_ptr.is_null() {
return Err("ndk_context vm is null".to_string());
}
// SAFETY: ndk_context::android_context guarantees `vm` points at
// a live JavaVM* set by our bridge_init JNI hook. The unsafe
// block contains only the cast required by `JavaVM::from_raw`.
let jvm = unsafe { jni::JavaVM::from_raw(vm_ptr as *mut _) }
.map_err(|e| format!("jvm from_raw: {e}"))?;
let mut env = jvm
.attach_current_thread()
.map_err(|e| format!("attach: {e}"))?;
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
let service_name: JString = env
.new_string("audio")
.map_err(|e| format!("new_string: {e}"))?;
let audio_manager = env
.call_method(
&context_obj,
"getSystemService",
"(Ljava/lang/String;)Ljava/lang/Object;",
&[JValue::Object(&service_name.into())],
)
.map_err(|e| format!("getSystemService: {e}"))?
.l()
.map_err(|e| format!("getSystemService obj: {e}"))?;
if audio_manager.is_null() {
return Err("AudioManager service is null".to_string());
}
// AudioManager.MODE_IN_COMMUNICATION == 3.
env.call_method(&audio_manager, "setMode", "(I)V", &[JValue::Int(3)])
.map_err(|e| format!("setMode: {e}"))?;
Ok(())
}