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,
},
}
}
}
+142 -20
View File
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1306308591;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1322894465;
// Section: executor
@@ -432,6 +432,100 @@ fn wire__crate__api__get_transmit_mode_impl(
},
)
}
fn wire__crate__api__handle_interruption_began_impl(
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::<flutter_rust_bridge::for_generated::SseCodec, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "handle_interruption_began",
port: None,
mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
deserializer.end();
transform_result_sse::<_, ()>((move || {
let output_ok = Result::<_, ()>::Ok({
crate::api::handle_interruption_began();
})?;
Ok(output_ok)
})())
},
)
}
fn wire__crate__api__handle_interruption_ended_impl(
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::<flutter_rust_bridge::for_generated::SseCodec, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "handle_interruption_ended",
port: None,
mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_should_resume = <bool>::sse_decode(&mut deserializer);
deserializer.end();
transform_result_sse::<_, ()>((move || {
let output_ok = Result::<_, ()>::Ok({
crate::api::handle_interruption_ended(api_should_resume);
})?;
Ok(output_ok)
})())
},
)
}
fn wire__crate__api__handle_route_change_impl(
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::<flutter_rust_bridge::for_generated::SseCodec, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "handle_route_change",
port: None,
mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
deserializer.end();
transform_result_sse::<_, ()>((move || {
let output_ok = Result::<_, ()>::Ok({
crate::api::handle_route_change();
})?;
Ok(output_ok)
})())
},
)
}
fn wire__crate__api__init_storage_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -1314,6 +1408,14 @@ impl SseDecode for crate::api::BridgeEvent {
release_tail_ms: var_releaseTailMs,
};
}
9 => {
let mut var_began = <bool>::sse_decode(deserializer);
let mut var_shouldResume = <bool>::sse_decode(deserializer);
return crate::api::BridgeEvent::InterruptionState {
began: var_began,
should_resume: var_shouldResume,
};
}
_ => {
unimplemented!("");
}
@@ -1515,23 +1617,23 @@ fn pde_ffi_dispatcher_primary_impl(
9 => wire__crate__api__get_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
10 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
11 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
12 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
13 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
14 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
16 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
17 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
18 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
19 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
21 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
23 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
24 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
25 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
26 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
28 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
29 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
30 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
15 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
16 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
17 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
19 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
21 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
24 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
25 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
26 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
28 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
29 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
30 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
31 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
32 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
33 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -1545,8 +1647,11 @@ fn pde_ffi_dispatcher_sync_impl(
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
match func_id {
8 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len),
15 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
20 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
12 => wire__crate__api__handle_interruption_began_impl(ptr, rust_vec_len, data_len),
13 => wire__crate__api__handle_interruption_ended_impl(ptr, rust_vec_len, data_len),
14 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
18 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
23 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -1719,6 +1824,15 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent {
release_tail_ms.into_into_dart().into_dart(),
]
.into_dart(),
crate::api::BridgeEvent::InterruptionState {
began,
should_resume,
} => [
9.into_dart(),
began.into_into_dart().into_dart(),
should_resume.into_into_dart().into_dart(),
]
.into_dart(),
_ => {
unimplemented!("");
}
@@ -1984,6 +2098,14 @@ impl SseEncode for crate::api::BridgeEvent {
<bool>::sse_encode(mute, serializer);
<u32>::sse_encode(release_tail_ms, serializer);
}
crate::api::BridgeEvent::InterruptionState {
began,
should_resume,
} => {
<i32>::sse_encode(9, serializer);
<bool>::sse_encode(began, serializer);
<bool>::sse_encode(should_resume, serializer);
}
_ => {
unimplemented!("");
}
+1 -3
View File
@@ -102,9 +102,7 @@ impl From<chanora_core::CoreError> for BridgeError {
) => BridgeError::ServerRejected { code, message },
chanora_core::CoreError::Protocol(p) => BridgeError::Connection(format!("{p}")),
chanora_core::CoreError::Audio(a) => BridgeError::Connection(format!("audio: {a}")),
chanora_core::CoreError::Storage(s) => {
BridgeError::Connection(format!("storage: {s}"))
}
chanora_core::CoreError::Storage(s) => BridgeError::Connection(format!("storage: {s}")),
other => BridgeError::Unmapped(format!("{other}")),
}
}