feat(voice): harden Android audio and channel joins

This commit is contained in:
Edison Jwa
2026-05-19 01:58:07 +09:00
parent 29a553d4e1
commit 8c253f1d4d
23 changed files with 2948 additions and 363 deletions
+108
View File
@@ -983,6 +983,18 @@ pub enum BridgeEvent {
mute: bool,
/// Current release-tail in milliseconds (0..=500).
release_tail_ms: u32,
/// Last confirmed authoritative channel id.
current_channel_id: Option<u64>,
/// Non-authoritative pending join target channel id.
pending_target_channel_id: Option<u64>,
/// Whether a new join intent is currently allowed.
can_join: bool,
/// Whether leave intent is currently allowed.
can_leave: bool,
/// Join projection sync state.
join_sync_state: BridgeVoiceJoinSyncState,
/// Last stable join error code, if any.
join_error_code: Option<BridgeVoiceJoinErrorCode>,
},
/// iOS audio interruption state (SDD-101).
InterruptionState {
@@ -1013,6 +1025,44 @@ pub enum BridgeEvent {
},
}
/// Bridge mirror of core join projection sync state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BridgeVoiceJoinSyncState {
/// Reducer is ready to accept channel actions.
Ready,
/// Reducer is waiting on initial snapshot reconciliation.
SynchronizingInitialSnapshot,
/// Reducer is waiting on reconnect snapshot reconciliation.
SynchronizingReconnect,
}
/// Bridge mirror of stable join error/status codes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BridgeVoiceJoinErrorCode {
/// Duplicate same-target join intent was coalesced.
DuplicateSameTargetCoalesced,
/// A different target was requested while one is already pending.
JoinAlreadyPendingDifferentTarget,
/// Join denied by server policy/permission.
JoinDenied,
/// Join failed due to protocol-level error.
JoinProtocolFailure,
/// Join failed due to transport/network error.
JoinNetworkFailure,
/// Join timed out awaiting confirmation.
JoinTimeout,
/// Pending join was superseded by user leave.
JoinSupersededByLeave,
/// Stale join outcome was ignored.
JoinStaleOutcomeIgnored,
/// Authoritative membership reconciled to different channel.
JoinReconciledDifferentChannel,
/// Join command was rejected before send acceptance.
JoinCommandRejectedBeforeSend,
/// Join intent rejected while reducer synchronizing.
JoinCannotStartWhileSynchronizing,
}
/// Schema-controlled mirror of the Kotlin
/// `AndroidPermissionRequester.PermissionState` sealed class
/// (SDD-106 §5). Crosses the bridge as an enum so the Dart side can
@@ -1094,11 +1144,23 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
transmit_mode,
mute,
release_tail_ms,
current_channel_id,
pending_target_channel_id,
can_join,
can_leave,
join_sync_state,
join_error_code,
} => BridgeEvent::VoiceState {
in_channel,
transmit_mode: transmit_mode_from_u8(transmit_mode),
mute,
release_tail_ms,
current_channel_id,
pending_target_channel_id,
can_join,
can_leave,
join_sync_state: map_join_sync_state(join_sync_state),
join_error_code: join_error_code.map(map_join_error_code),
},
chanora_core::SessionEvent::InterruptionState {
began,
@@ -1111,6 +1173,52 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
}
}
fn map_join_sync_state(state: chanora_core::VoiceJoinSyncState) -> BridgeVoiceJoinSyncState {
match state {
chanora_core::VoiceJoinSyncState::Ready => BridgeVoiceJoinSyncState::Ready,
chanora_core::VoiceJoinSyncState::SynchronizingInitialSnapshot => {
BridgeVoiceJoinSyncState::SynchronizingInitialSnapshot
}
chanora_core::VoiceJoinSyncState::SynchronizingReconnect => {
BridgeVoiceJoinSyncState::SynchronizingReconnect
}
}
}
fn map_join_error_code(code: chanora_core::VoiceJoinErrorCode) -> BridgeVoiceJoinErrorCode {
match code {
chanora_core::VoiceJoinErrorCode::DuplicateSameTargetCoalesced => {
BridgeVoiceJoinErrorCode::DuplicateSameTargetCoalesced
}
chanora_core::VoiceJoinErrorCode::JoinAlreadyPendingDifferentTarget => {
BridgeVoiceJoinErrorCode::JoinAlreadyPendingDifferentTarget
}
chanora_core::VoiceJoinErrorCode::JoinDenied => BridgeVoiceJoinErrorCode::JoinDenied,
chanora_core::VoiceJoinErrorCode::JoinProtocolFailure => {
BridgeVoiceJoinErrorCode::JoinProtocolFailure
}
chanora_core::VoiceJoinErrorCode::JoinNetworkFailure => {
BridgeVoiceJoinErrorCode::JoinNetworkFailure
}
chanora_core::VoiceJoinErrorCode::JoinTimeout => BridgeVoiceJoinErrorCode::JoinTimeout,
chanora_core::VoiceJoinErrorCode::JoinSupersededByLeave => {
BridgeVoiceJoinErrorCode::JoinSupersededByLeave
}
chanora_core::VoiceJoinErrorCode::JoinStaleOutcomeIgnored => {
BridgeVoiceJoinErrorCode::JoinStaleOutcomeIgnored
}
chanora_core::VoiceJoinErrorCode::JoinReconciledDifferentChannel => {
BridgeVoiceJoinErrorCode::JoinReconciledDifferentChannel
}
chanora_core::VoiceJoinErrorCode::JoinCommandRejectedBeforeSend => {
BridgeVoiceJoinErrorCode::JoinCommandRejectedBeforeSend
}
chanora_core::VoiceJoinErrorCode::JoinCannotStartWhileSynchronizing => {
BridgeVoiceJoinErrorCode::JoinCannotStartWhileSynchronizing
}
}
}
/// Subscribe to lifecycle events. Each call yields a fresh
/// subscription; multiple subscribers are supported. On slow
/// consumers, events are dropped rather than blocking the supervisor