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:
@@ -24,6 +24,7 @@ use tokio::sync::{mpsc, oneshot};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use tsclientlib::data::{self, Channel, Client};
|
||||
use tsclientlib::prelude::*;
|
||||
use tsclientlib::{
|
||||
ChannelId as TsChannelId, Connection, DisconnectOptions, Identity, OutCommandExt, StreamItem,
|
||||
};
|
||||
@@ -66,6 +67,18 @@ impl Default for ConnectConfig {
|
||||
enum Request {
|
||||
Snapshot(oneshot::Sender<Result<ServerSnapshot, ProtocolError>>),
|
||||
Disconnect(oneshot::Sender<()>),
|
||||
/// Move self to a channel. Optional channel password.
|
||||
MoveToChannel {
|
||||
channel_id: u64,
|
||||
password: Option<String>,
|
||||
reply: oneshot::Sender<Result<(), ProtocolError>>,
|
||||
},
|
||||
/// Update own client mute state (input and/or output).
|
||||
SetMuted {
|
||||
input: Option<bool>,
|
||||
output: Option<bool>,
|
||||
reply: oneshot::Sender<Result<(), ProtocolError>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Why a [`ProtocolClient`] task ended. Distinguishes a user-driven
|
||||
@@ -201,6 +214,46 @@ impl ProtocolClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Move our own client into a channel. `password` is optional
|
||||
/// for password-protected channels.
|
||||
pub async fn move_to_channel(
|
||||
&self,
|
||||
channel_id: u64,
|
||||
password: Option<String>,
|
||||
) -> Result<(), ProtocolError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.tx
|
||||
.send(Request::MoveToChannel {
|
||||
channel_id,
|
||||
password,
|
||||
reply: tx,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
|
||||
rx.await
|
||||
.map_err(|_| ProtocolError::Lost("move_to_channel reply dropped".to_string()))?
|
||||
}
|
||||
|
||||
/// Update mute state on our own client. Pass `Some(_)` for the
|
||||
/// fields you want to change, `None` to leave a field as-is.
|
||||
pub async fn set_muted(
|
||||
&self,
|
||||
input: Option<bool>,
|
||||
output: Option<bool>,
|
||||
) -> Result<(), ProtocolError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.tx
|
||||
.send(Request::SetMuted {
|
||||
input,
|
||||
output,
|
||||
reply: tx,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ProtocolError::Lost("connection task is gone".to_string()))?;
|
||||
rx.await
|
||||
.map_err(|_| ProtocolError::Lost("set_muted reply dropped".to_string()))?
|
||||
}
|
||||
|
||||
/// Sender for outbound voice packets. Clone freely.
|
||||
pub fn voice_out(&self) -> mpsc::Sender<OutPacket> {
|
||||
self.voice_out_tx.clone()
|
||||
@@ -406,6 +459,14 @@ async fn connection_task(
|
||||
let snap = build_snapshot(&con);
|
||||
let _ = reply.send(snap);
|
||||
}
|
||||
Ok(Request::MoveToChannel { channel_id, password, reply }) => {
|
||||
let r = move_self_to(&mut con, channel_id, password.as_deref());
|
||||
let _ = reply.send(r);
|
||||
}
|
||||
Ok(Request::SetMuted { input, output, reply }) => {
|
||||
let r = set_self_muted(&mut con, input, output);
|
||||
let _ = reply.send(r);
|
||||
}
|
||||
Ok(Request::Disconnect(reply)) => {
|
||||
let _ = con.disconnect(DisconnectOptions::new());
|
||||
con.events().for_each(|_| future::ready(())).await;
|
||||
@@ -424,6 +485,62 @@ async fn connection_task(
|
||||
}
|
||||
}
|
||||
|
||||
/// Move our own client into `channel_id` with an optional password.
|
||||
/// Looks up our `own_client` in the current state and dispatches the
|
||||
/// generated `client_move` command via the `OutCommandExt` trait.
|
||||
fn move_self_to(
|
||||
con: &mut Connection,
|
||||
channel_id: u64,
|
||||
password: Option<&str>,
|
||||
) -> Result<(), ProtocolError> {
|
||||
let state = con
|
||||
.get_state()
|
||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
||||
let own_id = state.own_client;
|
||||
let own_client = state
|
||||
.clients
|
||||
.get(&own_id)
|
||||
.ok_or_else(|| ProtocolError::Backend("own_client not in state".to_string()))?;
|
||||
let target = TsChannelId(channel_id);
|
||||
let mut part = own_client.client_move(target);
|
||||
if let Some(pw) = password {
|
||||
part = part.set_password(pw);
|
||||
}
|
||||
part.send(con)
|
||||
.map_err(|e| ProtocolError::Backend(format!("client_move send: {e}")))?;
|
||||
info!(target: "chanora_protocol", channel_id, "client_move sent");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a `clientupdate` with the requested mute fields set. `None`
|
||||
/// fields are omitted so callers can toggle just one flag.
|
||||
fn set_self_muted(
|
||||
con: &mut Connection,
|
||||
input: Option<bool>,
|
||||
output: Option<bool>,
|
||||
) -> Result<(), ProtocolError> {
|
||||
if input.is_none() && output.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let part = {
|
||||
let state = con
|
||||
.get_state()
|
||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
||||
let mut p = state.client_update();
|
||||
if let Some(v) = input {
|
||||
p = p.set_input_muted(v);
|
||||
}
|
||||
if let Some(v) = output {
|
||||
p = p.set_output_muted(v);
|
||||
}
|
||||
p
|
||||
};
|
||||
part.send(con)
|
||||
.map_err(|e| ProtocolError::Backend(format!("client_update send: {e}")))?;
|
||||
info!(target: "chanora_protocol", ?input, ?output, "client_update sent");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extract the originating `client_id` from an inbound voice packet.
|
||||
fn packet_sender_id(buf: &InAudioBuf) -> Option<u64> {
|
||||
use tsproto_packets::packets::AudioData;
|
||||
|
||||
Reference in New Issue
Block a user