feat(ios,p0): iOS P0 platform, audio fixes, channel UX

This commit is contained in:
Edison Jwa
2026-05-17 22:00:00 +09:00
parent a1fefc8ab6
commit 7a59f5b9a1
38 changed files with 1705 additions and 674 deletions
+110 -32
View File
@@ -48,8 +48,8 @@ use tracing::{info, warn};
pub mod ptt;
pub use chanora_audio::{
AudioEngine, AudioEngineConfig, AudioTransmitGate, PttBackendDescriptor,
PttCapabilityLevel, ReleaseTailTimer, TransmitMode, TransmitModeSelector,
AudioEngine, AudioEngineConfig, AudioTransmitGate, PttBackendDescriptor, PttCapabilityLevel,
ReleaseTailTimer, TransmitMode, TransmitModeSelector,
};
pub use chanora_audio::{PttBinding, PttInputClass};
pub use chanora_diagnostics::{
@@ -172,6 +172,15 @@ pub enum SessionEvent {
/// Current release-tail in milliseconds (0..=500).
release_tail_ms: u32,
},
/// iOS audio-session interruption state (SDD-101). Emitted when
/// interruption begins and when it ends (with the platform hint
/// indicating whether audio should resume).
InterruptionState {
/// True when interruption began, false when interruption ended.
began: bool,
/// Platform-provided resume hint. For begin events this is false.
should_resume: bool,
},
}
/// Coarse OS-reported network state. Populated by the Flutter side
@@ -897,6 +906,53 @@ impl ChanoraSession {
Ok((audio.frames_sent(), audio.frames_received(), audio.ptt()))
}
/// iOS route-change hook (SDD-100). No-op when audio is not
/// running.
pub async fn ios_handle_route_change(&self) -> Result<(), CoreError> {
let guard = self.inner.lock().await;
if let Some(state) = guard.as_ref() {
if let Some(audio) = state.audio.as_ref() {
audio.ios_restart_voice_unit()?;
}
}
Ok(())
}
/// iOS interruption-began hook (SDD-101). No-op when audio is
/// not running.
pub async fn ios_handle_interruption_began(&self) -> Result<(), CoreError> {
let guard = self.inner.lock().await;
if let Some(state) = guard.as_ref() {
if let Some(audio) = state.audio.as_ref() {
audio.ios_pause_voice_unit()?;
}
}
let _ = self.events_tx.send(SessionEvent::InterruptionState {
began: true,
should_resume: false,
});
Ok(())
}
/// iOS interruption-ended hook (SDD-101). Resumes only when
/// `should_resume` is true.
pub async fn ios_handle_interruption_ended(&self, should_resume: bool) -> Result<(), CoreError> {
let _ = self.events_tx.send(SessionEvent::InterruptionState {
began: false,
should_resume,
});
if !should_resume {
return Ok(());
}
let guard = self.inner.lock().await;
if let Some(state) = guard.as_ref() {
if let Some(audio) = state.audio.as_ref() {
audio.ios_resume_voice_unit()?;
}
}
Ok(())
}
// ---------- v1 audio + PTT lifecycle (SDD-094/095/096) ----------
/// Idempotent helper that ensures the audio engine is running
@@ -953,11 +1009,33 @@ impl ChanoraSession {
// `ProtocolError::ServerRejected` and don't need the
// snapshot polling at all.
if let Err(e) = self.move_to_channel(channel_id, password).await {
// Roll the selector back so the UI doesn't display a
// fake "joined" state.
self.voice_selector.set_in_channel(false);
self.emit_voice_state(false).await;
return Err(e);
// TS3 error 0x0302 = `channel_already_in`: we're already
// in the target channel, so this is a no-op success.
// Rolling `in_channel` back to false would break PTT
// because the transmit-mode selector clamps
// `transmit_active` to false when `in_channel` is false.
// (SDD-094, SAD-081, SRS-204)
if matches!(
&e,
CoreError::Protocol(chanora_protocol::ProtocolError::ServerRejected {
code: 0x0302,
..
})
) {
info!(
target: "chanora_core",
channel_id,
"voice_join: already in channel (0x0302); treating as success"
);
// Fall through to audio-start + snapshot confirmation below.
} else {
// Genuine move failures (wrong password, no
// permission, channel full, etc.) must roll back
// local in-channel state.
self.voice_selector.set_in_channel(false);
self.emit_voice_state(false).await;
return Err(e);
}
}
// 2. Bring the audio engine up. Tolerate failure: the
// server-side channel move has ALREADY succeeded (step
@@ -1022,9 +1100,8 @@ impl ChanoraSession {
// Use a sentinel "unknown" code (the canonical
// TS3 error catalogue uses 0x0001 for `undefined`).
code: 0x0001,
message:
"channel move did not take effect server-side within timeout"
.to_string(),
message: "channel move did not take effect server-side within timeout"
.to_string(),
},
));
}
@@ -1036,10 +1113,7 @@ impl ChanoraSession {
/// Find our own client in a snapshot and return `(client_id,
/// channel_id)`. Used by `voice_join` to confirm the server
/// actually applied a channel move.
async fn find_own_in(
&self,
snap: &chanora_protocol::ServerSnapshot,
) -> Option<(u64, u64)> {
async fn find_own_in(&self, snap: &chanora_protocol::ServerSnapshot) -> Option<(u64, u64)> {
let own_id = snap.own_client_id;
let me = snap.clients.iter().find(|c| c.id.0 == own_id)?;
Some((me.id.0, me.channel.0))
@@ -1403,7 +1477,11 @@ async fn supervisor_loop(
// sleep and resets the attempt counter so the
// next outage starts with the smallest backoff
// window again.
enum SleepOutcome { Elapsed, NetworkUp, Cancelled }
enum SleepOutcome {
Elapsed,
NetworkUp,
Cancelled,
}
let outcome = tokio::select! {
biased;
_ = &mut cancel_rx => SleepOutcome::Cancelled,
@@ -1495,9 +1573,14 @@ async fn supervisor_loop(
if let Some(state) = guard.as_mut() {
let voice_out = state.protocol.voice_out();
if let Some(voice_in) = state.protocol.take_voice_in() {
let gate = chanora_audio::AudioTransmitGate::new(audio_cfg.ptt_initial);
let gate = chanora_audio::AudioTransmitGate::new(
audio_cfg.ptt_initial,
);
match chanora_audio::AudioEngine::start_with_gate(
audio_cfg, voice_out, voice_in, gate.clone(),
audio_cfg,
voice_out,
voice_in,
gate.clone(),
) {
Ok(engine) => {
state.audio = Some(engine);
@@ -1528,21 +1611,19 @@ async fn supervisor_loop(
);
}
}
let _ = events_tx
.send(SessionEvent::AudioStarted);
let _ = events_tx.send(SessionEvent::AudioStarted);
// Re-publish the post-reconnect
// capability (SRS-196 / SDD-091).
let d = controller.descriptor().await;
let _ = events_tx.send(
SessionEvent::PttCapability {
let _ =
events_tx.send(SessionEvent::PttCapability {
level: d.level.as_str().to_string(),
backend_id: d.backend_id.to_string(),
bound_input_class: d
.bound_input_class
.unwrap_or("")
.to_string(),
},
);
});
}
Err(e) => {
warn!(
@@ -1660,10 +1741,7 @@ mod tests {
let mut b = a.clone();
// User moves from channel 1 → 2. Counts unchanged.
b.clients[0].channel = chanora_protocol::ChannelId(2);
assert_ne!(
super::snapshot_signature(&a),
super::snapshot_signature(&b)
);
assert_ne!(super::snapshot_signature(&a), super::snapshot_signature(&b));
}
#[test]
@@ -1693,17 +1771,17 @@ mod tests {
};
let mut b = a.clone();
b.channels.reverse();
assert_eq!(
super::snapshot_signature(&a),
super::snapshot_signature(&b)
);
assert_eq!(super::snapshot_signature(&a), super::snapshot_signature(&b));
}
#[tokio::test]
async fn empty_address_is_rejected() {
let s = ChanoraSession::new();
let r = s.connect(ConnectConfig::default()).await;
assert!(matches!(r, Err(CoreError::Protocol(ProtocolError::Invalid(_)))));
assert!(matches!(
r,
Err(CoreError::Protocol(ProtocolError::Invalid(_)))
));
}
#[tokio::test]
+40 -16
View File
@@ -101,8 +101,14 @@ impl PttController {
/// `release_tail.key_down/key_up` so the 200 ms tail spec'd in
/// SDD-096 actually fires between key release and gate close.
pub fn new(release_tail: Arc<ReleaseTailTimer>) -> Arc<Self> {
Self::new_with_backend(release_tail, select_ptt_backend())
}
fn new_with_backend(
release_tail: Arc<ReleaseTailTimer>,
mut backend: Box<dyn DesktopPttBackend>,
) -> Arc<Self> {
let press_gate = AudioTransmitGate::new(false);
let mut backend = select_ptt_backend();
let initial_binding = PttBinding::none();
match backend.start(press_gate.clone(), initial_binding.clone()) {
Ok(()) => {
@@ -178,6 +184,14 @@ impl PttController {
})
}
#[cfg(test)]
fn new_for_test(release_tail: Arc<ReleaseTailTimer>) -> Arc<Self> {
Self::new_with_backend(
release_tail,
Box::new(chanora_audio::ptt_backends::FocusedPttBackend::new()),
)
}
/// Replace the active binding (SDD-088 public surface).
///
/// Returns the freshly-published descriptor so callers can
@@ -189,9 +203,7 @@ impl PttController {
binding: PttBinding,
) -> Result<PttBackendDescriptor, PttControllerError> {
let mut backend_guard = self.backend.lock().await;
let backend = backend_guard
.as_mut()
.ok_or(PttControllerError::NotArmed)?;
let backend = backend_guard.as_mut().ok_or(PttControllerError::NotArmed)?;
backend.rebind(binding.clone())?;
let descriptor = backend.descriptor();
// Record the new binding under its own mutex so the
@@ -298,7 +310,13 @@ mod tests {
use chanora_audio::{TransmitMode, TransmitModeSelector};
use std::time::Duration;
fn setup(tail_ms: u32) -> (AudioTransmitGate, Arc<TransmitModeSelector>, Arc<ReleaseTailTimer>) {
fn setup(
tail_ms: u32,
) -> (
AudioTransmitGate,
Arc<TransmitModeSelector>,
Arc<ReleaseTailTimer>,
) {
let gate = AudioTransmitGate::new(false);
let selector = Arc::new(TransmitModeSelector::new(gate.clone()));
selector.set_mode(TransmitMode::Ptt);
@@ -310,7 +328,7 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn controller_arms_and_reports_capability() {
let (_gate, _sel, tail) = setup(0);
let controller = PttController::new(tail);
let controller = PttController::new_for_test(tail);
let desc = controller.descriptor().await;
assert!(!desc.backend_id.is_empty());
let level = controller.current_capability();
@@ -322,7 +340,7 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn set_binding_updates_descriptor_watch() {
let (_gate, _sel, tail) = setup(0);
let controller = PttController::new(tail);
let controller = PttController::new_for_test(tail);
let mut desc_rx = controller.descriptor_watch();
let _ = desc_rx.borrow_and_update();
let binding = PttBinding {
@@ -330,7 +348,10 @@ mod tests {
platform_key: "Space".to_string(),
};
let new_desc = controller.set_binding(binding.clone()).await.unwrap();
assert_eq!(new_desc.backend_id, controller.descriptor().await.backend_id);
assert_eq!(
new_desc.backend_id,
controller.descriptor().await.backend_id
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -339,7 +360,7 @@ mod tests {
// a backend's raw key-down), expect the real transmit gate
// to follow with the configured tail on release.
let (real_gate, _sel, tail) = setup(80);
let controller = PttController::new(tail);
let controller = PttController::new_for_test(tail);
// Give the edge-watcher task a tick to subscribe.
tokio::time::sleep(Duration::from_millis(15)).await;
// Simulate backend key-down.
@@ -361,7 +382,7 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stop_clears_press_and_cancels_tail() {
let (real_gate, _sel, tail) = setup(200);
let controller = PttController::new(tail);
let controller = PttController::new_for_test(tail);
tokio::time::sleep(Duration::from_millis(15)).await;
controller.press_gate().set(true);
tokio::time::sleep(Duration::from_millis(15)).await;
@@ -383,7 +404,7 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn press_on_release_off_zero_tail() {
let (real_gate, _sel, tail) = setup(0);
let controller = PttController::new(tail);
let controller = PttController::new_for_test(tail);
tokio::time::sleep(Duration::from_millis(10)).await;
assert!(!real_gate.load(), "idle baseline must be off");
@@ -403,7 +424,7 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn press_on_release_off_default_tail() {
let (real_gate, _sel, tail) = setup(200);
let controller = PttController::new(tail);
let controller = PttController::new_for_test(tail);
tokio::time::sleep(Duration::from_millis(10)).await;
controller.press_gate().set(true);
@@ -413,7 +434,10 @@ mod tests {
controller.press_gate().set(false);
// Mid-tail: still on.
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(real_gate.load(), "tail window: pttActive must still be true");
assert!(
real_gate.load(),
"tail window: pttActive must still be true"
);
// Past the tail: off.
tokio::time::sleep(Duration::from_millis(220)).await;
@@ -456,7 +480,7 @@ mod windows_full_chain_tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn full_chain_press_release_zero_tail() {
let (real_gate, _sel, tail) = setup(0);
let controller = PttController::new(tail);
let controller = PttController::new_for_test(tail);
tokio::time::sleep(Duration::from_millis(10)).await;
assert!(!real_gate.load());
@@ -473,7 +497,7 @@ mod windows_full_chain_tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn full_chain_press_release_default_tail() {
let (real_gate, _sel, tail) = setup(200);
let controller = PttController::new(tail);
let controller = PttController::new_for_test(tail);
tokio::time::sleep(Duration::from_millis(10)).await;
controller.press_gate().set(true);
@@ -494,7 +518,7 @@ mod windows_full_chain_tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mid_press_rebind_abandons_in_flight_press() {
let (real_gate, _sel, tail) = setup(0);
let controller = PttController::new(tail);
let controller = PttController::new_for_test(tail);
tokio::time::sleep(Duration::from_millis(10)).await;
// Press.
+4 -1
View File
@@ -22,7 +22,10 @@ async fn init_storage_encrypts_identity_and_bookmark_passwords() {
// No identity yet.
let id_path = tmp.join("identity.tskey");
assert!(!id_path.exists(), "identity should not exist before first connect");
assert!(
!id_path.exists(),
"identity should not exist before first connect"
);
// A bookmark with a password lands as an encrypted blob.
let id = session