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
+75 -10
View File
@@ -68,7 +68,7 @@ pub fn bridge_init() {
// export depends on it (DEC-016: user-initiated only, never
// auto-upload). It runs alongside whatever platform sink
// exists below; both consume the same `tracing` events.
let redact_layer = chanora_core::RedactingLogLayer::new(log_sink().clone());
let redact_layer = chanora_core::RedactingLogLayer::new(log_sink().clone()).with_sanitizer();
// On Android, also fan tracing output out to logcat so a user can
// see protocol/audio diagnostics via `adb logcat -s chanora`.
@@ -146,8 +146,7 @@ pub fn log_file_path_str() -> String {
fn log_file_path() -> Option<std::path::PathBuf> {
#[cfg(target_os = "windows")]
{
let base = std::env::var_os("LOCALAPPDATA")
.or_else(|| std::env::var_os("APPDATA"))?;
let base = std::env::var_os("LOCALAPPDATA").or_else(|| std::env::var_os("APPDATA"))?;
Some(
std::path::PathBuf::from(base)
.join("app.chanora")
@@ -167,12 +166,18 @@ fn log_file_path() -> Option<std::path::PathBuf> {
.join("chanora.log"),
)
}
#[cfg(all(unix, not(target_os = "macos"), not(target_os = "android"), not(target_os = "ios")))]
#[cfg(all(
unix,
not(target_os = "macos"),
not(target_os = "android"),
not(target_os = "ios")
))]
{
let base = std::env::var_os("XDG_STATE_HOME")
.map(std::path::PathBuf::from)
.or_else(|| {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".local").join("state"))
std::env::var_os("HOME")
.map(|h| std::path::PathBuf::from(h).join(".local").join("state"))
})?;
Some(
base.join("app.chanora")
@@ -343,7 +348,11 @@ pub async fn connect(
let cfg = chanora_core::ConnectConfig {
address: host,
nickname,
password: if password.is_empty() { None } else { Some(password) },
password: if password.is_empty() {
None
} else {
Some(password)
},
identity: None,
ready_timeout: Duration::from_secs(15),
};
@@ -386,6 +395,34 @@ pub async fn is_connected() -> bool {
// flows through `voice_join` / `voice_leave`, which transparently
// drive `AudioEngine::ensure_running` / `shutdown_if_idle`.
/// Handle iOS AVAudioSession route changes (SDD-100).
#[frb(sync)]
pub fn handle_route_change() {
let result = runtime().block_on(async { session().ios_handle_route_change().await });
if let Err(e) = result {
warn!(target: "chanora_bridge", error = %e, "iOS route-change handling failed");
}
}
/// Handle iOS AVAudioSession interruption begin (SDD-101).
#[frb(sync)]
pub fn handle_interruption_began() {
let result = runtime().block_on(async { session().ios_handle_interruption_began().await });
if let Err(e) = result {
warn!(target: "chanora_bridge", error = %e, "iOS interruption-began handling failed");
}
}
/// Handle iOS AVAudioSession interruption end (SDD-101).
#[frb(sync)]
pub fn handle_interruption_ended(should_resume: bool) {
let result =
runtime().block_on(async { session().ios_handle_interruption_ended(should_resume).await });
if let Err(e) = result {
warn!(target: "chanora_bridge", error = %e, "iOS interruption-ended handling failed");
}
}
/// Set the push-to-talk state.
///
/// Superseded in v1 by [`set_transmit_mode`] + the binding capture
@@ -444,7 +481,11 @@ fn transmit_mode_from_u8(v: u8) -> BridgeTransmitMode {
/// brings up the audio engine if needed, and emits
/// `BridgeEvent::VoiceState`. `password` may be empty.
pub async fn voice_join(channel_id: u64, password: String) -> Result<(), BridgeError> {
let pw = if password.is_empty() { None } else { Some(password) };
let pw = if password.is_empty() {
None
} else {
Some(password)
};
runtime()
.spawn(async move { session().voice_join(channel_id, pw).await })
.await
@@ -578,7 +619,11 @@ pub async fn get_ptt_binding() -> (String, String) {
/// for password-protected channels — pass an empty string when not
/// required.
pub async fn move_to_channel(channel_id: u64, password: String) -> Result<(), BridgeError> {
let pw = if password.is_empty() { None } else { Some(password) };
let pw = if password.is_empty() {
None
} else {
Some(password)
};
runtime()
.spawn(async move { session().move_to_channel(channel_id, pw).await })
.await
@@ -640,9 +685,15 @@ pub struct BridgeAudioStats {
#[frb(sync)]
pub fn export_diagnostics() -> String {
let metadata = vec![
("crate_version".to_string(), env!("CARGO_PKG_VERSION").to_string()),
(
"crate_version".to_string(),
env!("CARGO_PKG_VERSION").to_string(),
),
("target_os".to_string(), std::env::consts::OS.to_string()),
("target_arch".to_string(), std::env::consts::ARCH.to_string()),
(
"target_arch".to_string(),
std::env::consts::ARCH.to_string(),
),
];
match chanora_core::DiagnosticExport::from_sink(log_sink(), metadata) {
Ok(exp) => exp.to_text(),
@@ -858,6 +909,13 @@ pub enum BridgeEvent {
/// Current release-tail in milliseconds (0..=500).
release_tail_ms: u32,
},
/// iOS audio interruption state (SDD-101).
InterruptionState {
/// True when interruption began, false when it ended.
began: bool,
/// Resume recommendation from the platform. False on begin.
should_resume: bool,
},
}
impl From<chanora_core::SessionEvent> for BridgeEvent {
@@ -902,6 +960,13 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
mute,
release_tail_ms,
},
chanora_core::SessionEvent::InterruptionState {
began,
should_resume,
} => BridgeEvent::InterruptionState {
began,
should_resume,
},
}
}
}