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:
@@ -52,7 +52,7 @@ pub use chanora_diagnostics::{
|
||||
pub use chanora_protocol::{
|
||||
ChannelInfo, ClientInfo, ConnectConfig, DisconnectReason, ProtocolError, ServerSnapshot,
|
||||
};
|
||||
pub use chanora_storage::IdentityFileStore;
|
||||
pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore};
|
||||
|
||||
/// Errors that can arise during top-level orchestration.
|
||||
#[derive(Debug, Error)]
|
||||
@@ -198,6 +198,10 @@ pub struct ChanoraSession {
|
||||
/// carries (or a fresh ephemeral one if that is also `None`).
|
||||
/// Wired by [`Self::init_storage`].
|
||||
identity_store: Arc<Mutex<Option<IdentityFileStore>>>,
|
||||
/// Bookmark store backed by SQLite (A.2 + External Beta
|
||||
/// extension). Lives alongside the identity file. Wired by
|
||||
/// [`Self::init_storage`].
|
||||
bookmark_store: Arc<Mutex<Option<BookmarkRepository>>>,
|
||||
}
|
||||
|
||||
impl ChanoraSession {
|
||||
@@ -210,6 +214,7 @@ impl ChanoraSession {
|
||||
events_tx,
|
||||
network_tx,
|
||||
identity_store: Arc::new(Mutex::new(None)),
|
||||
bookmark_store: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,12 +227,54 @@ impl ChanoraSession {
|
||||
/// Beta caveat: the file is *not* encrypted at rest — see
|
||||
/// `chanora_storage::IdentityFileStore` for the full gap notice.
|
||||
pub async fn init_storage(&self, dir: impl AsRef<std::path::Path>) -> Result<(), CoreError> {
|
||||
let dir = dir.as_ref();
|
||||
let store = IdentityFileStore::new(dir)?;
|
||||
info!(target: "chanora_core", path = ?store.path(), "identity store initialised");
|
||||
*self.identity_store.lock().await = Some(store);
|
||||
|
||||
let bookmarks = BookmarkRepository::new(dir)?;
|
||||
*self.bookmark_store.lock().await = Some(bookmarks);
|
||||
info!(target: "chanora_core", "bookmark store initialised");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List persisted bookmarks. Returns an empty list if the store
|
||||
/// has not been wired or has no entries.
|
||||
pub async fn list_bookmarks(&self) -> Result<Vec<Bookmark>, CoreError> {
|
||||
let guard = self.bookmark_store.lock().await;
|
||||
match guard.as_ref() {
|
||||
Some(repo) => Ok(repo.list()?),
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a bookmark. Returns the assigned id.
|
||||
pub async fn add_bookmark(&self, b: Bookmark) -> Result<i64, CoreError> {
|
||||
let guard = self.bookmark_store.lock().await;
|
||||
let repo = guard
|
||||
.as_ref()
|
||||
.ok_or(CoreError::Invariant("bookmark store not initialised"))?;
|
||||
Ok(repo.add(&b)?)
|
||||
}
|
||||
|
||||
/// Update an existing bookmark.
|
||||
pub async fn update_bookmark(&self, b: Bookmark) -> Result<(), CoreError> {
|
||||
let guard = self.bookmark_store.lock().await;
|
||||
let repo = guard
|
||||
.as_ref()
|
||||
.ok_or(CoreError::Invariant("bookmark store not initialised"))?;
|
||||
Ok(repo.update(&b)?)
|
||||
}
|
||||
|
||||
/// Delete a bookmark by id.
|
||||
pub async fn delete_bookmark(&self, id: i64) -> Result<(), CoreError> {
|
||||
let guard = self.bookmark_store.lock().await;
|
||||
let repo = guard
|
||||
.as_ref()
|
||||
.ok_or(CoreError::Invariant("bookmark store not initialised"))?;
|
||||
Ok(repo.delete(id)?)
|
||||
}
|
||||
|
||||
/// Push an OS connectivity update. Called by the bridge when
|
||||
/// `connectivity_plus` fires. Safe to call from any thread.
|
||||
pub fn set_network_state(&self, state: NetworkState) {
|
||||
@@ -382,6 +429,51 @@ impl ChanoraSession {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move our own client to `channel_id`. Optional channel
|
||||
/// `password` for password-protected channels (empty string
|
||||
/// counts as no password).
|
||||
pub async fn move_to_channel(
|
||||
&self,
|
||||
channel_id: u64,
|
||||
password: Option<String>,
|
||||
) -> Result<(), CoreError> {
|
||||
let guard = self.inner.lock().await;
|
||||
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
||||
state.protocol.move_to_channel(channel_id, password).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update self-mute state. `input` mutes the microphone, `output`
|
||||
/// mutes the local speaker for remote clients. Pass `None` to
|
||||
/// leave a field unchanged. Adjusting the local output mute also
|
||||
/// updates the audio engine's master output gain so playback
|
||||
/// silences immediately, independent of the server's broadcast.
|
||||
pub async fn set_self_muted(
|
||||
&self,
|
||||
input: Option<bool>,
|
||||
output: Option<bool>,
|
||||
) -> Result<(), CoreError> {
|
||||
let guard = self.inner.lock().await;
|
||||
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
||||
state.protocol.set_muted(input, output).await?;
|
||||
if let Some(muted) = output {
|
||||
if let Some(audio) = state.audio.as_ref() {
|
||||
audio.set_output_muted(muted);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set master output gain (0.0 = silent, 1.0 = unity). Errors
|
||||
/// only when audio is not started.
|
||||
pub async fn set_output_gain(&self, gain: f32) -> Result<(), CoreError> {
|
||||
let guard = self.inner.lock().await;
|
||||
let state = guard.as_ref().ok_or(CoreError::NotConnected)?;
|
||||
let audio = state.audio.as_ref().ok_or(CoreError::AudioNotStarted)?;
|
||||
audio.set_output_gain(gain);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read audio engine statistics: (frames_sent, frames_received, ptt_active).
|
||||
pub async fn audio_stats(&self) -> Result<(u32, u32, bool), CoreError> {
|
||||
let guard = self.inner.lock().await;
|
||||
|
||||
Reference in New Issue
Block a user