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:
@@ -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"
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user