feat(ios,p0): iOS P0 platform, audio fixes, channel UX
This commit is contained in:
+110
-32
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user