From 8c253f1d4d8c30b4698d9c86a6e3394953df929b Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Tue, 19 May 2026 01:58:07 +0900 Subject: [PATCH] feat(voice): harden Android audio and channel joins --- apps/chanora_flutter/lib/main.dart | 116 +- apps/chanora_flutter/lib/src/rust/api.dart | 44 +- .../lib/src/rust/api.freezed.dart | 44 +- .../lib/src/rust/frb_generated.dart | 203 ++++ .../lib/src/rust/frb_generated.io.dart | 78 ++ .../lib/src/rust/frb_generated.web.dart | 78 ++ .../lib/widgets/voice_compact.dart | 6 +- core/chanora_core/src/lib.rs | 366 +++++- crates/chanora_audio/benches/opus_codec.rs | 12 +- .../chanora_audio/benches/realtime_capture.rs | 4 +- .../chanora_audio/examples/emit_baseline.rs | 30 +- .../chanora_audio/src/android_voice_unit.rs | 325 ++++- crates/chanora_audio/src/engine.rs | 393 +++--- crates/chanora_audio/src/lib.rs | 7 +- .../chanora_audio/src/mobile_voice_backend.rs | 13 +- crates/chanora_audio/src/release_tail.rs | 2 +- crates/chanora_audio/src/transmit_selector.rs | 3 +- crates/chanora_bridge/src/api.rs | 108 ++ crates/chanora_bridge/src/frb_generated.rs | 213 ++++ crates/chanora_state/src/channel_join.rs | 1061 +++++++++++++++++ crates/chanora_state/src/lib.rs | 2 + docs/architecture/sad.md | 82 +- docs/architecture/sdd.md | 121 +- 23 files changed, 2948 insertions(+), 363 deletions(-) create mode 100644 crates/chanora_state/src/channel_join.rs diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 67a575e..4053d7c 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -252,6 +252,9 @@ class _BetaHomeState extends State<_BetaHome> { bool _hardMute = false; int _releaseTailMs = 200; BigInt? _currentVoiceChannelId; + BigInt? _pendingVoiceChannelId; + bool _canJoinVoiceChannel = true; + bool _canLeaveVoiceChannel = false; String? _lostReason; int? _reconnectAttempt; @@ -312,7 +315,8 @@ class _BetaHomeState extends State<_BetaHome> { bool _handleFocusedPttKey(KeyEvent event) { final label = _pttDisplayLabelForKey(event.logicalKey); - final isBoundKey = _pttBoundKeyLabel.isNotEmpty && label == _pttBoundKeyLabel; + final isBoundKey = + _pttBoundKeyLabel.isNotEmpty && label == _pttBoundKeyLabel; if (event is KeyUpEvent && _focusedPttHeldKeys.contains(event.logicalKey)) { if (_focusedPttHeldKeys.remove(event.logicalKey)) { unawaited(_setPtt(false)); @@ -423,6 +427,10 @@ class _BetaHomeState extends State<_BetaHome> { :final transmitMode, :final mute, :final releaseTailMs, + :final currentChannelId, + :final pendingTargetChannelId, + :final canJoin, + :final canLeave, ): setState(() { _inChannel = inChannel; @@ -430,6 +438,10 @@ class _BetaHomeState extends State<_BetaHome> { _hardMute = mute; _releaseTailMs = releaseTailMs; _audioStarted = inChannel; + _currentVoiceChannelId = currentChannelId; + _pendingVoiceChannelId = pendingTargetChannelId; + _canJoinVoiceChannel = canJoin; + _canLeaveVoiceChannel = canLeave; }); if (inChannel) { _ensureStatsTimer(); @@ -438,7 +450,6 @@ class _BetaHomeState extends State<_BetaHome> { _statsTimer?.cancel(); _statsTimer = null; _releaseFocusedPttIfHeld(); - setState(() => _currentVoiceChannelId = null); } if (transmitMode != rust.BridgeTransmitMode.ptt) { _releaseFocusedPttIfHeld(); @@ -485,10 +496,7 @@ class _BetaHomeState extends State<_BetaHome> { // remains the canonical Dart-side state holder (driven by // the MethodChannel); a future revision may expose a // setter so both paths converge on a single ValueNotifier. - case rust.BridgeEvent_PermissionState( - :final permission, - :final state, - ): + case rust.BridgeEvent_PermissionState(:final permission, :final state): debugPrint( 'bridge permission_state: permission=$permission state=$state', ); @@ -568,27 +576,27 @@ class _BetaHomeState extends State<_BetaHome> { void _showPermissionDeniedDialog(String rawError) { final l10n = AppL10n.of(context); - final isNetwork = - rawError.contains('9987') || rawError.contains('connect'); + final isNetwork = rawError.contains('9987') || rawError.contains('connect'); showDialog( context: context, builder: (ctx) => AlertDialog( - title: - Text(isNetwork ? l10n.networkPermissionTitle : l10n.permissionDenied), + title: Text( + isNetwork ? l10n.networkPermissionTitle : l10n.permissionDenied, + ), content: Text( - isNetwork ? l10n.networkPermissionBody : l10n.microphonePermissionBody), + isNetwork + ? l10n.networkPermissionBody + : l10n.microphonePermissionBody, + ), actions: [ if (Platform.isMacOS) TextButton( onPressed: () { Navigator.pop(ctx); try { - Process.run( - 'open', - [ - 'x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork', - ], - ); + Process.run('open', [ + 'x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork', + ]); } catch (_) {} }, child: Text(l10n.networkPermissionOpenSettings), @@ -649,6 +657,8 @@ class _BetaHomeState extends State<_BetaHome> { } Future _onJoinChannel(rust.BridgeChannel ch) async { + if (!_canJoinVoiceChannel) return; + if (_pendingVoiceChannelId != null) return; if (ch.id == _currentVoiceChannelId) return; final l10n = AppL10n.of(context); final messenger = ScaffoldMessenger.of(context); @@ -689,7 +699,7 @@ class _BetaHomeState extends State<_BetaHome> { } await rust.voiceJoin(channelId: ch.id, password: password ?? ''); if (!mounted) return; - setState(() => _currentVoiceChannelId = ch.id); + unawaited(_onRefresh()); } catch (e) { if (!mounted) return; // Surface as a SnackBar so the user sees it even while @@ -753,10 +763,9 @@ class _BetaHomeState extends State<_BetaHome> { // ignore: unused_element Future _onLeaveVoice() async { + if (!_canLeaveVoiceChannel) return; try { await rust.voiceLeave(); - if (!mounted) return; - setState(() => _currentVoiceChannelId = null); } catch (e) { if (!mounted) return; setState(() => _error = e.toString()); @@ -916,6 +925,9 @@ class _BetaHomeState extends State<_BetaHome> { _outputMuted = false; _inChannel = false; _currentVoiceChannelId = null; + _pendingVoiceChannelId = null; + _canJoinVoiceChannel = true; + _canLeaveVoiceChannel = false; }); _releaseFocusedPttIfHeld(); } @@ -932,17 +944,6 @@ class _BetaHomeState extends State<_BetaHome> { void _applySnapshot(rust.BridgeSnapshot snap) { _snapshot = snap; - if (!_inChannel) { - _currentVoiceChannelId = null; - return; - } - for (final client in snap.clients) { - if (client.id == snap.ownClientId) { - _currentVoiceChannelId = client.channel; - return; - } - } - _currentVoiceChannelId = null; } Future _onShowDiagnostics(BuildContext context) async { @@ -1335,6 +1336,10 @@ class _BetaHomeState extends State<_BetaHome> { final snapshotView = _SnapshotView( snapshot: _snapshot!, currentVoiceChannelId: _currentVoiceChannelId, + pendingVoiceChannelId: _pendingVoiceChannelId, + hasJoinPending: _pendingVoiceChannelId != null, + canJoinVoiceChannel: _canJoinVoiceChannel, + canLeaveVoiceChannel: _canLeaveVoiceChannel, onJoinChannel: _onJoinChannel, onLeaveVoice: _onLeaveVoice, ); @@ -1346,8 +1351,7 @@ class _BetaHomeState extends State<_BetaHome> { SizedBox( width: voiceBarWidthWide, child: Column( - crossAxisAlignment: - CrossAxisAlignment.stretch, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ banner, const SizedBox(height: 12), @@ -1376,8 +1380,7 @@ class _BetaHomeState extends State<_BetaHome> { onTap: () => _onOpenVoiceDetailsSheet(), ), if (_inChannel && - _transmitMode == - rust.BridgeTransmitMode.ptt) ...[ + _transmitMode == rust.BridgeTransmitMode.ptt) ...[ const SizedBox(height: 8), VoicePttButton( active: _audioStats?.pttActive ?? false, @@ -1410,7 +1413,12 @@ class _BetaHomeState extends State<_BetaHome> { ], ), ), - Expanded(child: Padding(padding: const EdgeInsets.all(16), child: bodyContent)), + Expanded( + child: Padding( + padding: const EdgeInsets.all(16), + child: bodyContent, + ), + ), ], ), ), @@ -1418,14 +1426,8 @@ class _BetaHomeState extends State<_BetaHome> { } return Scaffold( - appBar: AppBar( - title: headerTitle, - actions: headerActions, - ), - body: Padding( - padding: const EdgeInsets.all(16), - child: bodyContent, - ), + appBar: AppBar(title: headerTitle, actions: headerActions), + body: Padding(padding: const EdgeInsets.all(16), child: bodyContent), ); } } @@ -1991,12 +1993,20 @@ class _SnapshotView extends StatelessWidget { const _SnapshotView({ required this.snapshot, required this.currentVoiceChannelId, + required this.pendingVoiceChannelId, + required this.hasJoinPending, + required this.canJoinVoiceChannel, + required this.canLeaveVoiceChannel, required this.onJoinChannel, required this.onLeaveVoice, }); final rust.BridgeSnapshot snapshot; final BigInt? currentVoiceChannelId; + final BigInt? pendingVoiceChannelId; + final bool hasJoinPending; + final bool canJoinVoiceChannel; + final bool canLeaveVoiceChannel; final ValueChanged onJoinChannel; final VoidCallback onLeaveVoice; @@ -2077,17 +2087,27 @@ class _SnapshotView extends StatelessWidget { subtitle: Text('id=${ch.id} parent=${ch.parent}'), trailing: IconButton( icon: Icon( - ch.id == currentVoiceChannelId ? Icons.logout : Icons.login, + ch.id == currentVoiceChannelId + ? Icons.logout + : ch.id == pendingVoiceChannelId + ? Icons.hourglass_top + : Icons.login, ), tooltip: ch.id == currentVoiceChannelId ? l10n.leaveChannelAction + : ch.id == pendingVoiceChannelId + ? l10n.statusConnecting : l10n.joinChannelAction, - onPressed: ch.id == currentVoiceChannelId - ? onLeaveVoice + onPressed: hasJoinPending + ? null + : ch.id == pendingVoiceChannelId + ? null + : ch.id == currentVoiceChannelId + ? (canLeaveVoiceChannel ? onLeaveVoice : null) : () => onJoinChannel(ch), ), selected: ch.id == currentVoiceChannelId, - onTap: ch.id == currentVoiceChannelId + onTap: hasJoinPending || !canJoinVoiceChannel || ch.id == currentVoiceChannelId ? null : () => onJoinChannel(ch), ), diff --git a/apps/chanora_flutter/lib/src/rust/api.dart b/apps/chanora_flutter/lib/src/rust/api.dart index d0e8ce6..3c208b4 100644 --- a/apps/chanora_flutter/lib/src/rust/api.dart +++ b/apps/chanora_flutter/lib/src/rust/api.dart @@ -9,8 +9,8 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; import 'package:freezed_annotation/freezed_annotation.dart' hide protected; part 'api.freezed.dart'; -// These functions are ignored because they are not marked as `pub`: `log_file_path`, `log_sink`, `open_log_file`, `permission_events`, `publish_permission_state`, `runtime`, `session`, `transmit_mode_from_u8` -// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from` +// These functions are ignored because they are not marked as `pub`: `log_file_path`, `log_sink`, `map_join_error_code`, `map_join_sync_state`, `open_log_file`, `permission_events`, `publish_permission_state`, `runtime`, `session`, `transmit_mode_from_u8` +// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_fields_are_eq`, `assert_fields_are_eq`, `assert_fields_are_eq`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `eq`, `eq`, `eq`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `from`, `from`, `from`, `from`, `from`, `from`, `from`, `from` // These functions are ignored (category: IgnoreBecauseExplicitAttribute): `from_kotlin_str`, `to_permission_gate` /// Return the platform-conventional log-file path as a string, or @@ -454,6 +454,24 @@ sealed class BridgeEvent with _$BridgeEvent { /// Current release-tail in milliseconds (0..=500). required int releaseTailMs, + + /// Last confirmed authoritative channel id. + BigInt? currentChannelId, + + /// Non-authoritative pending join target channel id. + BigInt? pendingTargetChannelId, + + /// Whether a new join intent is currently allowed. + required bool canJoin, + + /// Whether leave intent is currently allowed. + required bool canLeave, + + /// Join projection sync state. + required BridgeVoiceJoinSyncState joinSyncState, + + /// Last stable join error code, if any. + BridgeVoiceJoinErrorCode? joinErrorCode, }) = BridgeEvent_VoiceState; /// iOS audio interruption state (SDD-101). @@ -587,6 +605,28 @@ enum BridgeTransmitMode { voiceActivity, } +/// Bridge mirror of stable join error/status codes. +enum BridgeVoiceJoinErrorCode { + duplicateSameTargetCoalesced, + joinAlreadyPendingDifferentTarget, + joinDenied, + joinProtocolFailure, + joinNetworkFailure, + joinTimeout, + joinSupersededByLeave, + joinStaleOutcomeIgnored, + joinReconciledDifferentChannel, + joinCommandRejectedBeforeSend, + joinCannotStartWhileSynchronizing, +} + +/// Bridge mirror of core join projection sync state. +enum BridgeVoiceJoinSyncState { + ready, + synchronizingInitialSnapshot, + synchronizingReconnect, +} + /// Schema-controlled mirror of the Kotlin /// `AndroidPermissionRequester.PermissionState` sealed class /// (SDD-106 §5). Crosses the bridge as an enum so the Dart side can diff --git a/apps/chanora_flutter/lib/src/rust/api.freezed.dart b/apps/chanora_flutter/lib/src/rust/api.freezed.dart index 42274f6..fe1d46d 100644 --- a/apps/chanora_flutter/lib/src/rust/api.freezed.dart +++ b/apps/chanora_flutter/lib/src/rust/api.freezed.dart @@ -146,7 +146,7 @@ return permissionState(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( int channels, int clients)? snapshotChanged,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,TResult Function( String permission, PermissionStateKind state)? permissionState,required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen({TResult Function( String serverName)? connected,TResult Function( String reason)? lost,TResult Function( int attempt, int delaySecs)? reconnecting,TResult Function( String reason)? disconnected,TResult Function()? audioStarted,TResult Function()? audioStopped,TResult Function( int channels, int clients)? snapshotChanged,TResult Function( String level, String backendId, String boundInputClass)? pttCapability,TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,TResult Function( String permission, PermissionStateKind state)? permissionState,required TResult orElse(),}) {final _that = this; switch (_that) { case BridgeEvent_Connected() when connected != null: return connected(_that.serverName);case BridgeEvent_Lost() when lost != null: @@ -157,7 +157,7 @@ return audioStarted();case BridgeEvent_AudioStopped() when audioStopped != null: return audioStopped();case BridgeEvent_SnapshotChanged() when snapshotChanged != null: return snapshotChanged(_that.channels,_that.clients);case BridgeEvent_PttCapability() when pttCapability != null: return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case BridgeEvent_VoiceState() when voiceState != null: -return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs);case BridgeEvent_InterruptionState() when interruptionState != null: +return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null: return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null: return permissionState(_that.permission,_that.state);case _: return orElse(); @@ -177,7 +177,7 @@ return permissionState(_that.permission,_that.state);case _: /// } /// ``` -@optionalTypeArgs TResult when({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( int channels, int clients) snapshotChanged,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,required TResult Function( String permission, PermissionStateKind state) permissionState,}) {final _that = this; +@optionalTypeArgs TResult when({required TResult Function( String serverName) connected,required TResult Function( String reason) lost,required TResult Function( int attempt, int delaySecs) reconnecting,required TResult Function( String reason) disconnected,required TResult Function() audioStarted,required TResult Function() audioStopped,required TResult Function( int channels, int clients) snapshotChanged,required TResult Function( String level, String backendId, String boundInputClass) pttCapability,required TResult Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,required TResult Function( String permission, PermissionStateKind state) permissionState,}) {final _that = this; switch (_that) { case BridgeEvent_Connected(): return connected(_that.serverName);case BridgeEvent_Lost(): @@ -188,7 +188,7 @@ return audioStarted();case BridgeEvent_AudioStopped(): return audioStopped();case BridgeEvent_SnapshotChanged(): return snapshotChanged(_that.channels,_that.clients);case BridgeEvent_PttCapability(): return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case BridgeEvent_VoiceState(): -return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs);case BridgeEvent_InterruptionState(): +return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState(): return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState(): return permissionState(_that.permission,_that.state);} } @@ -204,7 +204,7 @@ return permissionState(_that.permission,_that.state);} /// } /// ``` -@optionalTypeArgs TResult? whenOrNull({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( int channels, int clients)? snapshotChanged,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,TResult? Function( String permission, PermissionStateKind state)? permissionState,}) {final _that = this; +@optionalTypeArgs TResult? whenOrNull({TResult? Function( String serverName)? connected,TResult? Function( String reason)? lost,TResult? Function( int attempt, int delaySecs)? reconnecting,TResult? Function( String reason)? disconnected,TResult? Function()? audioStarted,TResult? Function()? audioStopped,TResult? Function( int channels, int clients)? snapshotChanged,TResult? Function( String level, String backendId, String boundInputClass)? pttCapability,TResult? Function( bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,TResult? Function( String permission, PermissionStateKind state)? permissionState,}) {final _that = this; switch (_that) { case BridgeEvent_Connected() when connected != null: return connected(_that.serverName);case BridgeEvent_Lost() when lost != null: @@ -215,7 +215,7 @@ return audioStarted();case BridgeEvent_AudioStopped() when audioStopped != null: return audioStopped();case BridgeEvent_SnapshotChanged() when snapshotChanged != null: return snapshotChanged(_that.channels,_that.clients);case BridgeEvent_PttCapability() when pttCapability != null: return pttCapability(_that.level,_that.backendId,_that.boundInputClass);case BridgeEvent_VoiceState() when voiceState != null: -return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs);case BridgeEvent_InterruptionState() when interruptionState != null: +return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs,_that.currentChannelId,_that.pendingTargetChannelId,_that.canJoin,_that.canLeave,_that.joinSyncState,_that.joinErrorCode);case BridgeEvent_InterruptionState() when interruptionState != null: return interruptionState(_that.began,_that.shouldResume);case BridgeEvent_PermissionState() when permissionState != null: return permissionState(_that.permission,_that.state);case _: return null; @@ -710,7 +710,7 @@ as String, class BridgeEvent_VoiceState extends BridgeEvent { - const BridgeEvent_VoiceState({required this.inChannel, required this.transmitMode, required this.mute, required this.releaseTailMs}): super._(); + const BridgeEvent_VoiceState({required this.inChannel, required this.transmitMode, required this.mute, required this.releaseTailMs, this.currentChannelId, this.pendingTargetChannelId, required this.canJoin, required this.canLeave, required this.joinSyncState, this.joinErrorCode}): super._(); /// True when the session is currently joined to a voice @@ -722,6 +722,18 @@ class BridgeEvent_VoiceState extends BridgeEvent { final bool mute; /// Current release-tail in milliseconds (0..=500). final int releaseTailMs; +/// Last confirmed authoritative channel id. + final BigInt? currentChannelId; +/// Non-authoritative pending join target channel id. + final BigInt? pendingTargetChannelId; +/// Whether a new join intent is currently allowed. + final bool canJoin; +/// Whether leave intent is currently allowed. + final bool canLeave; +/// Join projection sync state. + final BridgeVoiceJoinSyncState joinSyncState; +/// Last stable join error code, if any. + final BridgeVoiceJoinErrorCode? joinErrorCode; /// Create a copy of BridgeEvent /// with the given fields replaced by the non-null parameter values. @@ -733,16 +745,16 @@ $BridgeEvent_VoiceStateCopyWith get copyWith => _$Bridge @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_VoiceState&&(identical(other.inChannel, inChannel) || other.inChannel == inChannel)&&(identical(other.transmitMode, transmitMode) || other.transmitMode == transmitMode)&&(identical(other.mute, mute) || other.mute == mute)&&(identical(other.releaseTailMs, releaseTailMs) || other.releaseTailMs == releaseTailMs)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_VoiceState&&(identical(other.inChannel, inChannel) || other.inChannel == inChannel)&&(identical(other.transmitMode, transmitMode) || other.transmitMode == transmitMode)&&(identical(other.mute, mute) || other.mute == mute)&&(identical(other.releaseTailMs, releaseTailMs) || other.releaseTailMs == releaseTailMs)&&(identical(other.currentChannelId, currentChannelId) || other.currentChannelId == currentChannelId)&&(identical(other.pendingTargetChannelId, pendingTargetChannelId) || other.pendingTargetChannelId == pendingTargetChannelId)&&(identical(other.canJoin, canJoin) || other.canJoin == canJoin)&&(identical(other.canLeave, canLeave) || other.canLeave == canLeave)&&(identical(other.joinSyncState, joinSyncState) || other.joinSyncState == joinSyncState)&&(identical(other.joinErrorCode, joinErrorCode) || other.joinErrorCode == joinErrorCode)); } @override -int get hashCode => Object.hash(runtimeType,inChannel,transmitMode,mute,releaseTailMs); +int get hashCode => Object.hash(runtimeType,inChannel,transmitMode,mute,releaseTailMs,currentChannelId,pendingTargetChannelId,canJoin,canLeave,joinSyncState,joinErrorCode); @override String toString() { - return 'BridgeEvent.voiceState(inChannel: $inChannel, transmitMode: $transmitMode, mute: $mute, releaseTailMs: $releaseTailMs)'; + return 'BridgeEvent.voiceState(inChannel: $inChannel, transmitMode: $transmitMode, mute: $mute, releaseTailMs: $releaseTailMs, currentChannelId: $currentChannelId, pendingTargetChannelId: $pendingTargetChannelId, canJoin: $canJoin, canLeave: $canLeave, joinSyncState: $joinSyncState, joinErrorCode: $joinErrorCode)'; } @@ -753,7 +765,7 @@ abstract mixin class $BridgeEvent_VoiceStateCopyWith<$Res> implements $BridgeEve factory $BridgeEvent_VoiceStateCopyWith(BridgeEvent_VoiceState value, $Res Function(BridgeEvent_VoiceState) _then) = _$BridgeEvent_VoiceStateCopyWithImpl; @useResult $Res call({ - bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs + bool inChannel, BridgeTransmitMode transmitMode, bool mute, int releaseTailMs, BigInt? currentChannelId, BigInt? pendingTargetChannelId, bool canJoin, bool canLeave, BridgeVoiceJoinSyncState joinSyncState, BridgeVoiceJoinErrorCode? joinErrorCode }); @@ -770,13 +782,19 @@ class _$BridgeEvent_VoiceStateCopyWithImpl<$Res> /// Create a copy of BridgeEvent /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') $Res call({Object? inChannel = null,Object? transmitMode = null,Object? mute = null,Object? releaseTailMs = null,}) { +@pragma('vm:prefer-inline') $Res call({Object? inChannel = null,Object? transmitMode = null,Object? mute = null,Object? releaseTailMs = null,Object? currentChannelId = freezed,Object? pendingTargetChannelId = freezed,Object? canJoin = null,Object? canLeave = null,Object? joinSyncState = null,Object? joinErrorCode = freezed,}) { return _then(BridgeEvent_VoiceState( inChannel: null == inChannel ? _self.inChannel : inChannel // ignore: cast_nullable_to_non_nullable as bool,transmitMode: null == transmitMode ? _self.transmitMode : transmitMode // ignore: cast_nullable_to_non_nullable as BridgeTransmitMode,mute: null == mute ? _self.mute : mute // ignore: cast_nullable_to_non_nullable as bool,releaseTailMs: null == releaseTailMs ? _self.releaseTailMs : releaseTailMs // ignore: cast_nullable_to_non_nullable -as int, +as int,currentChannelId: freezed == currentChannelId ? _self.currentChannelId : currentChannelId // ignore: cast_nullable_to_non_nullable +as BigInt?,pendingTargetChannelId: freezed == pendingTargetChannelId ? _self.pendingTargetChannelId : pendingTargetChannelId // ignore: cast_nullable_to_non_nullable +as BigInt?,canJoin: null == canJoin ? _self.canJoin : canJoin // ignore: cast_nullable_to_non_nullable +as bool,canLeave: null == canLeave ? _self.canLeave : canLeave // ignore: cast_nullable_to_non_nullable +as bool,joinSyncState: null == joinSyncState ? _self.joinSyncState : joinSyncState // ignore: cast_nullable_to_non_nullable +as BridgeVoiceJoinSyncState,joinErrorCode: freezed == joinErrorCode ? _self.joinErrorCode : joinErrorCode // ignore: cast_nullable_to_non_nullable +as BridgeVoiceJoinErrorCode?, )); } diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.dart index 752e00d..c95d41f 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.dart @@ -1111,6 +1111,20 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return dco_decode_bridge_bookmark(raw); } + @protected + BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dco_decode_bridge_voice_join_error_code(raw); + } + + @protected + BigInt dco_decode_box_autoadd_u_64(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return dco_decode_u_64(raw); + } + @protected BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -1232,6 +1246,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { transmitMode: dco_decode_bridge_transmit_mode(raw[2]), mute: dco_decode_bool(raw[3]), releaseTailMs: dco_decode_u_32(raw[4]), + currentChannelId: dco_decode_opt_box_autoadd_u_64(raw[5]), + pendingTargetChannelId: dco_decode_opt_box_autoadd_u_64(raw[6]), + canJoin: dco_decode_bool(raw[7]), + canLeave: dco_decode_bool(raw[8]), + joinSyncState: dco_decode_bridge_voice_join_sync_state(raw[9]), + joinErrorCode: + dco_decode_opt_box_autoadd_bridge_voice_join_error_code(raw[10]), ); case 9: return BridgeEvent_InterruptionState( @@ -1283,6 +1304,22 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return BridgeTransmitMode.values[raw as int]; } + @protected + BridgeVoiceJoinErrorCode dco_decode_bridge_voice_join_error_code( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return BridgeVoiceJoinErrorCode.values[raw as int]; + } + + @protected + BridgeVoiceJoinSyncState dco_decode_bridge_voice_join_sync_state( + dynamic raw, + ) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return BridgeVoiceJoinSyncState.values[raw as int]; + } + @protected double dco_decode_f_32(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -1325,6 +1362,21 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return raw as Uint8List; } + @protected + BridgeVoiceJoinErrorCode? + dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw == null + ? null + : dco_decode_box_autoadd_bridge_voice_join_error_code(raw); + } + + @protected + BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw == null ? null : dco_decode_box_autoadd_u_64(raw); + } + @protected PermissionStateKind dco_decode_permission_state_kind(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs @@ -1415,6 +1467,20 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return (sse_decode_bridge_bookmark(deserializer)); } + @protected + BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + return (sse_decode_bridge_voice_join_error_code(deserializer)); + } + + @protected + BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return (sse_decode_u_64(deserializer)); + } + @protected BridgeAudioStats sse_decode_bridge_audio_stats(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -1554,11 +1620,32 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var var_transmitMode = sse_decode_bridge_transmit_mode(deserializer); var var_mute = sse_decode_bool(deserializer); var var_releaseTailMs = sse_decode_u_32(deserializer); + var var_currentChannelId = sse_decode_opt_box_autoadd_u_64( + deserializer, + ); + var var_pendingTargetChannelId = sse_decode_opt_box_autoadd_u_64( + deserializer, + ); + var var_canJoin = sse_decode_bool(deserializer); + var var_canLeave = sse_decode_bool(deserializer); + var var_joinSyncState = sse_decode_bridge_voice_join_sync_state( + deserializer, + ); + var var_joinErrorCode = + sse_decode_opt_box_autoadd_bridge_voice_join_error_code( + deserializer, + ); return BridgeEvent_VoiceState( inChannel: var_inChannel, transmitMode: var_transmitMode, mute: var_mute, releaseTailMs: var_releaseTailMs, + currentChannelId: var_currentChannelId, + pendingTargetChannelId: var_pendingTargetChannelId, + canJoin: var_canJoin, + canLeave: var_canLeave, + joinSyncState: var_joinSyncState, + joinErrorCode: var_joinErrorCode, ); case 9: var var_began = sse_decode_bool(deserializer); @@ -1627,6 +1714,24 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return BridgeTransmitMode.values[inner]; } + @protected + BridgeVoiceJoinErrorCode sse_decode_bridge_voice_join_error_code( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_i_32(deserializer); + return BridgeVoiceJoinErrorCode.values[inner]; + } + + @protected + BridgeVoiceJoinSyncState sse_decode_bridge_voice_join_sync_state( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_i_32(deserializer); + return BridgeVoiceJoinSyncState.values[inner]; + } + @protected double sse_decode_f_32(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -1694,6 +1799,33 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return deserializer.buffer.getUint8List(len_); } + @protected + BridgeVoiceJoinErrorCode? + sse_decode_opt_box_autoadd_bridge_voice_join_error_code( + SseDeserializer deserializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + if (sse_decode_bool(deserializer)) { + return (sse_decode_box_autoadd_bridge_voice_join_error_code( + deserializer, + )); + } else { + return null; + } + } + + @protected + BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + if (sse_decode_bool(deserializer)) { + return (sse_decode_box_autoadd_u_64(deserializer)); + } else { + return null; + } + } + @protected PermissionStateKind sse_decode_permission_state_kind( SseDeserializer deserializer, @@ -1794,6 +1926,21 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_bridge_bookmark(self, serializer); } + @protected + void sse_encode_box_autoadd_bridge_voice_join_error_code( + BridgeVoiceJoinErrorCode self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_bridge_voice_join_error_code(self, serializer); + } + + @protected + void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_u_64(self, serializer); + } + @protected void sse_encode_bridge_audio_stats( BridgeAudioStats self, @@ -1909,12 +2056,27 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { transmitMode: final transmitMode, mute: final mute, releaseTailMs: final releaseTailMs, + currentChannelId: final currentChannelId, + pendingTargetChannelId: final pendingTargetChannelId, + canJoin: final canJoin, + canLeave: final canLeave, + joinSyncState: final joinSyncState, + joinErrorCode: final joinErrorCode, ): sse_encode_i_32(8, serializer); sse_encode_bool(inChannel, serializer); sse_encode_bridge_transmit_mode(transmitMode, serializer); sse_encode_bool(mute, serializer); sse_encode_u_32(releaseTailMs, serializer); + sse_encode_opt_box_autoadd_u_64(currentChannelId, serializer); + sse_encode_opt_box_autoadd_u_64(pendingTargetChannelId, serializer); + sse_encode_bool(canJoin, serializer); + sse_encode_bool(canLeave, serializer); + sse_encode_bridge_voice_join_sync_state(joinSyncState, serializer); + sse_encode_opt_box_autoadd_bridge_voice_join_error_code( + joinErrorCode, + serializer, + ); case BridgeEvent_InterruptionState( began: final began, shouldResume: final shouldResume, @@ -1974,6 +2136,24 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_i_32(self.index, serializer); } + @protected + void sse_encode_bridge_voice_join_error_code( + BridgeVoiceJoinErrorCode self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.index, serializer); + } + + @protected + void sse_encode_bridge_voice_join_sync_state( + BridgeVoiceJoinSyncState self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.index, serializer); + } + @protected void sse_encode_f_32(double self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs @@ -2038,6 +2218,29 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { serializer.buffer.putUint8List(self); } + @protected + void sse_encode_opt_box_autoadd_bridge_voice_join_error_code( + BridgeVoiceJoinErrorCode? self, + SseSerializer serializer, + ) { + // Codec=Sse (Serialization based), see doc to use other codecs + + sse_encode_bool(self != null, serializer); + if (self != null) { + sse_encode_box_autoadd_bridge_voice_join_error_code(self, serializer); + } + } + + @protected + void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + + sse_encode_bool(self != null, serializer); + if (self != null) { + sse_encode_box_autoadd_u_64(self, serializer); + } + } + @protected void sse_encode_permission_state_kind( PermissionStateKind self, diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart index 8ed27b5..dc9f82d 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.io.dart @@ -36,6 +36,14 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BridgeBookmark dco_decode_box_autoadd_bridge_bookmark(dynamic raw); + @protected + BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code( + dynamic raw, + ); + + @protected + BigInt dco_decode_box_autoadd_u_64(dynamic raw); + @protected BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw); @@ -66,6 +74,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BridgeTransmitMode dco_decode_bridge_transmit_mode(dynamic raw); + @protected + BridgeVoiceJoinErrorCode dco_decode_bridge_voice_join_error_code(dynamic raw); + + @protected + BridgeVoiceJoinSyncState dco_decode_bridge_voice_join_sync_state(dynamic raw); + @protected double dco_decode_f_32(dynamic raw); @@ -87,6 +101,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + @protected + BridgeVoiceJoinErrorCode? + dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw); + + @protected + BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw); + @protected PermissionStateKind dco_decode_permission_state_kind(dynamic raw); @@ -127,6 +148,14 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code( + SseDeserializer deserializer, + ); + + @protected + BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer); + @protected BridgeAudioStats sse_decode_bridge_audio_stats(SseDeserializer deserializer); @@ -163,6 +192,16 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + BridgeVoiceJoinErrorCode sse_decode_bridge_voice_join_error_code( + SseDeserializer deserializer, + ); + + @protected + BridgeVoiceJoinSyncState sse_decode_bridge_voice_join_sync_state( + SseDeserializer deserializer, + ); + @protected double sse_decode_f_32(SseDeserializer deserializer); @@ -190,6 +229,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + @protected + BridgeVoiceJoinErrorCode? + sse_decode_opt_box_autoadd_bridge_voice_join_error_code( + SseDeserializer deserializer, + ); + + @protected + BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer); + @protected PermissionStateKind sse_decode_permission_state_kind( SseDeserializer deserializer, @@ -241,6 +289,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_box_autoadd_bridge_voice_join_error_code( + BridgeVoiceJoinErrorCode self, + SseSerializer serializer, + ); + + @protected + void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer); + @protected void sse_encode_bridge_audio_stats( BridgeAudioStats self, @@ -289,6 +346,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_bridge_voice_join_error_code( + BridgeVoiceJoinErrorCode self, + SseSerializer serializer, + ); + + @protected + void sse_encode_bridge_voice_join_sync_state( + BridgeVoiceJoinSyncState self, + SseSerializer serializer, + ); + @protected void sse_encode_f_32(double self, SseSerializer serializer); @@ -322,6 +391,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_opt_box_autoadd_bridge_voice_join_error_code( + BridgeVoiceJoinErrorCode? self, + SseSerializer serializer, + ); + + @protected + void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer); + @protected void sse_encode_permission_state_kind( PermissionStateKind self, diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart index 1638f24..e6d3d14 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.web.dart @@ -38,6 +38,14 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BridgeBookmark dco_decode_box_autoadd_bridge_bookmark(dynamic raw); + @protected + BridgeVoiceJoinErrorCode dco_decode_box_autoadd_bridge_voice_join_error_code( + dynamic raw, + ); + + @protected + BigInt dco_decode_box_autoadd_u_64(dynamic raw); + @protected BridgeAudioStats dco_decode_bridge_audio_stats(dynamic raw); @@ -68,6 +76,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected BridgeTransmitMode dco_decode_bridge_transmit_mode(dynamic raw); + @protected + BridgeVoiceJoinErrorCode dco_decode_bridge_voice_join_error_code(dynamic raw); + + @protected + BridgeVoiceJoinSyncState dco_decode_bridge_voice_join_sync_state(dynamic raw); + @protected double dco_decode_f_32(dynamic raw); @@ -89,6 +103,13 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Uint8List dco_decode_list_prim_u_8_strict(dynamic raw); + @protected + BridgeVoiceJoinErrorCode? + dco_decode_opt_box_autoadd_bridge_voice_join_error_code(dynamic raw); + + @protected + BigInt? dco_decode_opt_box_autoadd_u_64(dynamic raw); + @protected PermissionStateKind dco_decode_permission_state_kind(dynamic raw); @@ -129,6 +150,14 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + BridgeVoiceJoinErrorCode sse_decode_box_autoadd_bridge_voice_join_error_code( + SseDeserializer deserializer, + ); + + @protected + BigInt sse_decode_box_autoadd_u_64(SseDeserializer deserializer); + @protected BridgeAudioStats sse_decode_bridge_audio_stats(SseDeserializer deserializer); @@ -165,6 +194,16 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseDeserializer deserializer, ); + @protected + BridgeVoiceJoinErrorCode sse_decode_bridge_voice_join_error_code( + SseDeserializer deserializer, + ); + + @protected + BridgeVoiceJoinSyncState sse_decode_bridge_voice_join_sync_state( + SseDeserializer deserializer, + ); + @protected double sse_decode_f_32(SseDeserializer deserializer); @@ -192,6 +231,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer); + @protected + BridgeVoiceJoinErrorCode? + sse_decode_opt_box_autoadd_bridge_voice_join_error_code( + SseDeserializer deserializer, + ); + + @protected + BigInt? sse_decode_opt_box_autoadd_u_64(SseDeserializer deserializer); + @protected PermissionStateKind sse_decode_permission_state_kind( SseDeserializer deserializer, @@ -243,6 +291,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_box_autoadd_bridge_voice_join_error_code( + BridgeVoiceJoinErrorCode self, + SseSerializer serializer, + ); + + @protected + void sse_encode_box_autoadd_u_64(BigInt self, SseSerializer serializer); + @protected void sse_encode_bridge_audio_stats( BridgeAudioStats self, @@ -291,6 +348,18 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_bridge_voice_join_error_code( + BridgeVoiceJoinErrorCode self, + SseSerializer serializer, + ); + + @protected + void sse_encode_bridge_voice_join_sync_state( + BridgeVoiceJoinSyncState self, + SseSerializer serializer, + ); + @protected void sse_encode_f_32(double self, SseSerializer serializer); @@ -324,6 +393,15 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { SseSerializer serializer, ); + @protected + void sse_encode_opt_box_autoadd_bridge_voice_join_error_code( + BridgeVoiceJoinErrorCode? self, + SseSerializer serializer, + ); + + @protected + void sse_encode_opt_box_autoadd_u_64(BigInt? self, SseSerializer serializer); + @protected void sse_encode_permission_state_kind( PermissionStateKind self, diff --git a/apps/chanora_flutter/lib/widgets/voice_compact.dart b/apps/chanora_flutter/lib/widgets/voice_compact.dart index 417c24b..132f1dd 100644 --- a/apps/chanora_flutter/lib/widgets/voice_compact.dart +++ b/apps/chanora_flutter/lib/widgets/voice_compact.dart @@ -353,9 +353,9 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { final levelActive = stats?.pttActive ?? false; final isPtt = _mode == rust.BridgeTransmitMode.ptt; - // Route picker only meaningful on iOS + Android where the OS - // owns audio routing. Desktop hosts skip the tile entirely. - final showRoutePicker = !kIsWeb && (Platform.isIOS || Platform.isAndroid); + // Route picker is iOS-only; the picker uses AVAudioSession-backed + // widgets that must not be constructed on Android. + final showRoutePicker = !kIsWeb && Platform.isIOS; return SafeArea( child: SingleChildScrollView( diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index a131476..17cd194 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -45,6 +45,11 @@ use tokio::sync::{broadcast, oneshot, watch, Mutex}; use tokio::task::JoinHandle; use tracing::{info, warn}; +use chanora_state::channel_join::{ + self, AuthoritativeSource, ChannelId as JoinChannelId, ChannelJoinEvent, ChannelJoinState, + ConnectionEpoch, JoinFailureKind, +}; + pub mod ptt; pub use chanora_audio::{ @@ -171,6 +176,20 @@ pub enum SessionEvent { mute: bool, /// Current release-tail in milliseconds (0..=500). release_tail_ms: u32, + /// Last confirmed authoritative channel id from the + /// `channel_join` reducer projection. + current_channel_id: Option, + /// Non-authoritative pending target channel id from the + /// reducer projection. + pending_target_channel_id: Option, + /// Whether the reducer currently allows a new join intent. + can_join: bool, + /// Whether the reducer currently allows leave intent. + can_leave: bool, + /// Join projection synchronization state. + join_sync_state: VoiceJoinSyncState, + /// Last stable sanitized join error code, if any. + join_error_code: Option, }, /// iOS audio-session interruption state (SDD-101). Emitted when /// interruption begins and when it ends (with the platform hint @@ -183,6 +202,44 @@ pub enum SessionEvent { }, } +/// Bridge-safe mirror of channel-join projection sync state. +#[derive(Debug, Clone, Copy)] +pub enum VoiceJoinSyncState { + /// Reducer is ready to accept channel actions. + Ready, + /// Reducer is synchronizing against an initial snapshot. + SynchronizingInitialSnapshot, + /// Reducer is synchronizing after reconnect. + SynchronizingReconnect, +} + +/// Bridge-safe mirror of stable channel-join error codes. +#[derive(Debug, Clone, Copy)] +pub enum VoiceJoinErrorCode { + /// 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, +} + /// Coarse OS-reported network state. Populated by the Flutter side /// via `connectivity_plus`; on platforms where no signal is wired /// we stay at `Unknown` forever and the supervisor falls back to @@ -235,6 +292,7 @@ struct ConnectedState { /// Audio supervision state. Wrapped in Arc> so the /// supervisor and the public API both see updates. sup_inner: Arc>, + join_state: ChannelJoinState, } /// The top-level Chanora session. Owns at most one active server @@ -281,6 +339,7 @@ pub struct ChanoraSession { /// from this value. Also persisted to the identity store so /// the binding survives app restarts. pending_binding: Arc>>, + next_connection_epoch: Arc>, } impl ChanoraSession { @@ -308,9 +367,17 @@ impl ChanoraSession { release_tail, pending_binding: Arc::new(Mutex::new(None)), ptt_watchdog: Arc::new(Mutex::new(None)), + next_connection_epoch: Arc::new(Mutex::new(1)), } } + async fn allocate_connection_epoch(&self) -> ConnectionEpoch { + let mut guard = self.next_connection_epoch.lock().await; + let epoch = *guard; + *guard = guard.saturating_add(1); + ConnectionEpoch(epoch) + } + /// Wire a directory-backed identity store. Called by the bridge /// during `bridge_init` once Flutter has resolved the platform /// app-private storage directory. Subsequent [`Self::connect`] @@ -474,6 +541,17 @@ impl ChanoraSession { let client = chanora_protocol::ProtocolClient::connect(cfg.clone()).await?; let snap = client.snapshot().await?; + let epoch = self.allocate_connection_epoch().await; + let mut join_state = ChannelJoinState::new(epoch); + let initial_channel = self.find_own_in(&snap).await.map(|(_, channel)| JoinChannelId(channel)); + let _ = channel_join::reduce( + &mut join_state, + ChannelJoinEvent::SnapshotReady { + current_channel: initial_channel, + epoch, + now: std::time::Instant::now(), + }, + ); // Set up the supervisor. let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); @@ -499,6 +577,7 @@ impl ChanoraSession { self.voice_selector.clone(), self.pending_binding.clone(), self.release_tail.clone(), + self.next_connection_epoch.clone(), )); let _ = self.events_tx.send(SessionEvent::Connected { @@ -513,6 +592,7 @@ impl ChanoraSession { supervisor: Some(supervisor), cfg, sup_inner, + join_state, }); // TS3 servers auto-place a newly-connected client into the @@ -536,7 +616,15 @@ impl ChanoraSession { drop(guard); if let Some((_my_id, _channel_id)) = self.find_own_in(&snap).await { self.voice_selector.set_in_channel(true); - self.emit_voice_state(true).await; + let projection = { + let guard = self.inner.lock().await; + guard + .as_ref() + .map(|state| channel_join::project(&state.join_state)) + }; + if let Some(projection) = projection { + self.emit_voice_state(projection).await; + } // Bring the audio engine up so the user can immediately // hear other speakers + transmit on PTT. Tolerates // failure the same way voice_join does: server-side @@ -560,9 +648,23 @@ impl ChanoraSession { /// Return a fresh snapshot of the current server state. pub async fn snapshot(&self) -> Result { - let guard = self.inner.lock().await; - let state = guard.as_ref().ok_or(CoreError::NotConnected)?; - Ok(state.protocol.snapshot().await?) + let mut guard = self.inner.lock().await; + let state = guard.as_mut().ok_or(CoreError::NotConnected)?; + let snap = state.protocol.snapshot().await?; + let current_channel = self + .find_own_in(&snap) + .await + .map(|(_, channel)| JoinChannelId(channel)); + let epoch = state.join_state.authoritative.confirmed_epoch; + let _ = channel_join::reduce( + &mut state.join_state, + ChannelJoinEvent::SnapshotReady { + current_channel, + epoch, + now: std::time::Instant::now(), + }, + ); + Ok(snap) } /// True if a connection is currently active. @@ -1002,13 +1104,59 @@ impl ChanoraSession { channel_id: u64, password: Option, ) -> Result<(), CoreError> { + let mut guard = self.inner.lock().await; + let state = guard.as_mut().ok_or(CoreError::NotConnected)?; + let join_start = std::time::Instant::now(); + let requested = channel_join::reduce( + &mut state.join_state, + ChannelJoinEvent::UserJoinRequested { + target_channel: JoinChannelId(channel_id), + now: join_start, + }, + ); + match requested.status { + channel_join::JoinReduceStatus::Rejected( + channel_join::JoinIntentRejected::JoinAlreadyPendingDifferentTarget, + ) => { + return Err(CoreError::Protocol(chanora_protocol::ProtocolError::ServerRejected { + code: 0x7001, + message: "join already pending for a different target".to_string(), + })); + } + channel_join::JoinReduceStatus::Rejected( + channel_join::JoinIntentRejected::CannotJoinWhileSynchronizing, + ) => { + return Err(CoreError::Protocol(chanora_protocol::ProtocolError::ServerRejected { + code: 0x7002, + message: "join cannot start while synchronizing".to_string(), + })); + } + channel_join::JoinReduceStatus::CoalescedSameTarget => return Ok(()), + _ => {} + } + + let generation = requested + .projection + .pending_generation + .ok_or(CoreError::Invariant("missing pending generation after join request"))?; + let pending_key = state + .join_state + .pending + .filter(|pending| pending.generation == generation) + .map(|pending| channel_join::JoinOutcomeKey { + connection_epoch: pending.connection_epoch, + generation: pending.generation, + request_id: pending.request_id, + }) + .ok_or(CoreError::Invariant("missing pending key after join request"))?; + // 1. Send the move command. With send_with_result the // adapter now correlates against the server's typed // error reply, so on rejection (no permission, wrong // password, channel full) we get a concrete // `ProtocolError::ServerRejected` and don't need the // snapshot polling at all. - if let Err(e) = self.move_to_channel(channel_id, password).await { + if let Err(e) = state.protocol.move_to_channel(channel_id, password).await { // 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 @@ -1017,10 +1165,10 @@ impl ChanoraSession { // (SDD-094, SAD-081, SRS-204) if matches!( &e, - CoreError::Protocol(chanora_protocol::ProtocolError::ServerRejected { + chanora_protocol::ProtocolError::ServerRejected { code: 0x0302, .. - }) + } ) { info!( target: "chanora_core", @@ -1029,14 +1177,27 @@ impl ChanoraSession { ); // 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); + let _ = channel_join::reduce( + &mut state.join_state, + ChannelJoinEvent::JoinCommandRejectedBeforeSend { + generation, + error: JoinFailureKind::Denied, + }, + ); + let projection = channel_join::project(&state.join_state); + let still_in_channel = projection.current_channel.is_some(); + self.voice_selector.set_in_channel(still_in_channel); + self.emit_voice_state(projection).await; + return Err(CoreError::Protocol(e)); } } + let _ = channel_join::reduce( + &mut state.join_state, + ChannelJoinEvent::ProtocolJoinSucceeded { + key: pending_key, + }, + ); + drop(guard); // 2. Bring the audio engine up. Tolerate failure: the // server-side channel move has ALREADY succeeded (step // 1), so the user is in the channel from every other @@ -1093,8 +1254,20 @@ impl ChanoraSession { tokio::time::sleep(std::time::Duration::from_millis(80)).await; }; if !confirmed { - self.voice_selector.set_in_channel(false); - self.emit_voice_state(false).await; + let mut guard = self.inner.lock().await; + if let Some(state) = guard.as_mut() { + let _ = channel_join::reduce( + &mut state.join_state, + ChannelJoinEvent::JoinTimeout { + key: pending_key, + now: std::time::Instant::now(), + }, + ); + let projection = channel_join::project(&state.join_state); + let still_in_channel = projection.current_channel.is_some(); + self.voice_selector.set_in_channel(still_in_channel); + self.emit_voice_state(projection).await; + } return Err(CoreError::Protocol( chanora_protocol::ProtocolError::ServerRejected { // Use a sentinel "unknown" code (the canonical @@ -1105,8 +1278,28 @@ impl ChanoraSession { }, )); } + let mut guard = self.inner.lock().await; + if let Some(state) = guard.as_mut() { + let epoch = state.join_state.authoritative.confirmed_epoch; + let _ = channel_join::reduce( + &mut state.join_state, + ChannelJoinEvent::AuthoritativeSelfMove { + channel: Some(JoinChannelId(channel_id)), + epoch, + source: AuthoritativeSource::Snapshot, + }, + ); + } self.voice_selector.set_in_channel(true); - self.emit_voice_state(true).await; + let projection = { + let guard = self.inner.lock().await; + guard + .as_ref() + .map(|state| channel_join::project(&state.join_state)) + }; + if let Some(projection) = projection { + self.emit_voice_state(projection).await; + } Ok(()) } @@ -1124,9 +1317,21 @@ impl ChanoraSession { /// to false), tears down the audio engine, and emits /// [`SessionEvent::VoiceState`]. pub async fn voice_leave(&self) -> Result<(), CoreError> { - self.voice_selector.set_in_channel(false); + let mut guard = self.inner.lock().await; + if let Some(state) = guard.as_mut() { + let _ = channel_join::reduce( + &mut state.join_state, + ChannelJoinEvent::UserLeaveRequested { + now: std::time::Instant::now(), + }, + ); + let projection = channel_join::project(&state.join_state); + self.voice_selector + .set_in_channel(projection.current_channel.is_some()); + self.emit_voice_state(projection).await; + } + drop(guard); self.shutdown_audio_if_idle().await; - self.emit_voice_state(false).await; Ok(()) } @@ -1145,8 +1350,15 @@ impl ChanoraSession { ); } } - let in_channel = self.voice_selector.in_channel(); - self.emit_voice_state(in_channel).await; + let projection = { + let guard = self.inner.lock().await; + guard + .as_ref() + .map(|state| channel_join::project(&state.join_state)) + }; + if let Some(projection) = projection { + self.emit_voice_state(projection).await; + } Ok(()) } @@ -1161,8 +1373,15 @@ impl ChanoraSession { /// channel or PTT state. pub async fn set_hard_mute(&self, muted: bool) -> Result<(), CoreError> { self.voice_selector.set_hard_mute(muted); - let in_channel = self.voice_selector.in_channel(); - self.emit_voice_state(in_channel).await; + let projection = { + let guard = self.inner.lock().await; + guard + .as_ref() + .map(|state| channel_join::project(&state.join_state)) + }; + if let Some(projection) = projection { + self.emit_voice_state(projection).await; + } Ok(()) } @@ -1185,8 +1404,15 @@ impl ChanoraSession { ); } } - let in_channel = self.voice_selector.in_channel(); - self.emit_voice_state(in_channel).await; + let projection = { + let guard = self.inner.lock().await; + guard + .as_ref() + .map(|state| channel_join::project(&state.join_state)) + }; + if let Some(projection) = projection { + self.emit_voice_state(projection).await; + } Ok(()) } @@ -1209,12 +1435,19 @@ impl ChanoraSession { self.release_tail.clone() } - async fn emit_voice_state(&self, in_channel: bool) { + async fn emit_voice_state(&self, projection: channel_join::ChannelJoinProjection) { + let in_channel = projection.current_channel.is_some(); let _ = self.events_tx.send(SessionEvent::VoiceState { in_channel, transmit_mode: self.voice_selector.mode().as_u8(), mute: self.voice_selector.hard_mute(), release_tail_ms: self.release_tail.tail_ms(), + current_channel_id: projection.current_channel.map(|id| id.0), + pending_target_channel_id: projection.pending_target.map(|id| id.0), + can_join: projection.can_join, + can_leave: projection.can_leave, + join_sync_state: map_join_sync_state(projection.sync_state), + join_error_code: projection.last_join_error.map(map_join_error_code), }); } @@ -1289,6 +1522,7 @@ async fn supervisor_loop( voice_selector: Arc, pending_binding: Arc>>, release_tail: Arc, + next_connection_epoch: Arc>, ) { let mut lost_rx = initial_lost_rx; let mut probe = initial_probe; @@ -1521,10 +1755,23 @@ async fn supervisor_loop( info!(target: "chanora_core", attempt, "reconnect: dialling"); match chanora_protocol::ProtocolClient::connect(cfg.clone()).await { Ok(new_client) => { + let new_epoch = { + let mut guard = next_connection_epoch.lock().await; + let epoch = *guard; + *guard = guard.saturating_add(1); + ConnectionEpoch(epoch) + }; // Successful reconnect. Snapshot for the event. - let snap_name = match new_client.snapshot().await { - Ok(s) => s.server_name, - Err(_) => String::new(), + let (snap_name, snap_current_channel) = match new_client.snapshot().await { + Ok(s) => { + let current_channel = s + .clients + .iter() + .find(|c| c.id.0 == s.own_client_id) + .map(|c| JoinChannelId(c.channel.0)); + (s.server_name, current_channel) + } + Err(_) => (String::new(), None), }; let new_lost_rx = match new_client.take_loss_notifier() { Some(rx) => rx, @@ -1555,6 +1802,25 @@ async fn supervisor_loop( let old = std::mem::replace(&mut state.protocol, new_client); drop(old); + let _ = channel_join::reduce( + &mut state.join_state, + ChannelJoinEvent::ReconnectStarted { + new_epoch, + now: std::time::Instant::now(), + }, + ); + let _ = channel_join::reduce( + &mut state.join_state, + ChannelJoinEvent::SnapshotReady { + current_channel: snap_current_channel, + epoch: new_epoch, + now: std::time::Instant::now(), + }, + ); + voice_selector.set_in_channel( + state.join_state.authoritative.current_channel.is_some(), + ); + let sup = sup_inner.lock().await; sup.audio_running && sup.audio_cfg.is_some() }; @@ -1693,6 +1959,50 @@ fn snapshot_signature(snap: &ServerSnapshot) -> u64 { h.finish() } +fn map_join_sync_state(sync: channel_join::ChannelJoinSyncState) -> VoiceJoinSyncState { + match sync { + channel_join::ChannelJoinSyncState::Ready => VoiceJoinSyncState::Ready, + channel_join::ChannelJoinSyncState::Synchronizing { + reason: channel_join::SyncReason::InitialSnapshot, + .. + } => VoiceJoinSyncState::SynchronizingInitialSnapshot, + channel_join::ChannelJoinSyncState::Synchronizing { + reason: channel_join::SyncReason::Reconnect, + .. + } => VoiceJoinSyncState::SynchronizingReconnect, + } +} + +fn map_join_error_code(code: channel_join::JoinErrorCode) -> VoiceJoinErrorCode { + match code { + channel_join::JoinErrorCode::DuplicateSameTargetCoalesced => { + VoiceJoinErrorCode::DuplicateSameTargetCoalesced + } + channel_join::JoinErrorCode::JoinAlreadyPendingDifferentTarget => { + VoiceJoinErrorCode::JoinAlreadyPendingDifferentTarget + } + channel_join::JoinErrorCode::JoinDenied => VoiceJoinErrorCode::JoinDenied, + channel_join::JoinErrorCode::JoinProtocolFailure => VoiceJoinErrorCode::JoinProtocolFailure, + channel_join::JoinErrorCode::JoinNetworkFailure => VoiceJoinErrorCode::JoinNetworkFailure, + channel_join::JoinErrorCode::JoinTimeout => VoiceJoinErrorCode::JoinTimeout, + channel_join::JoinErrorCode::JoinSupersededByLeave => { + VoiceJoinErrorCode::JoinSupersededByLeave + } + channel_join::JoinErrorCode::JoinStaleOutcomeIgnored => { + VoiceJoinErrorCode::JoinStaleOutcomeIgnored + } + channel_join::JoinErrorCode::JoinReconciledDifferentChannel => { + VoiceJoinErrorCode::JoinReconciledDifferentChannel + } + channel_join::JoinErrorCode::JoinCommandRejectedBeforeSend => { + VoiceJoinErrorCode::JoinCommandRejectedBeforeSend + } + channel_join::JoinErrorCode::JoinCannotStartWhileSynchronizing => { + VoiceJoinErrorCode::JoinCannotStartWhileSynchronizing + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/chanora_audio/benches/opus_codec.rs b/crates/chanora_audio/benches/opus_codec.rs index c792b03..0bb891c 100644 --- a/crates/chanora_audio/benches/opus_codec.rs +++ b/crates/chanora_audio/benches/opus_codec.rs @@ -12,8 +12,8 @@ use audiopus::coder::{Decoder, Encoder}; use audiopus::packet::Packet; use audiopus::{Application, Channels, MutSignals, SampleRate}; -use std::convert::TryFrom; use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use std::convert::TryFrom; mod common; use common::{synthetic_opus_bytes, synthetic_opus_frame}; @@ -43,11 +43,17 @@ fn bench_opus_decode_latency(c: &mut Criterion) { b.iter(|| { let input = Packet::try_from(black_box(&bytes[..])).expect("packet"); let output = MutSignals::try_from(&mut pcm_out[..]).expect("signals"); - let n = dec.decode_float(Some(input), output, false).expect("decode"); + let n = dec + .decode_float(Some(input), output, false) + .expect("decode"); black_box(n); }); }); } -criterion_group!(opus_codec, bench_opus_encode_latency, bench_opus_decode_latency); +criterion_group!( + opus_codec, + bench_opus_encode_latency, + bench_opus_decode_latency +); criterion_main!(opus_codec); diff --git a/crates/chanora_audio/benches/realtime_capture.rs b/crates/chanora_audio/benches/realtime_capture.rs index b030c53..1d3f71e 100644 --- a/crates/chanora_audio/benches/realtime_capture.rs +++ b/crates/chanora_audio/benches/realtime_capture.rs @@ -80,9 +80,7 @@ fn bench_capture_alloc_count(c: &mut Criterion) { // here and the emitter reads it directly. if let Ok(dir) = std::env::var("CARGO_TARGET_DIR") .map(std::path::PathBuf::from) - .or_else(|_| { - std::env::current_dir().map(|d| d.join("target")) - }) + .or_else(|_| std::env::current_dir().map(|d| d.join("target"))) { let path = dir.join("criterion").join("capture_alloc_count.sidecar"); if let Some(parent) = path.parent() { diff --git a/crates/chanora_audio/examples/emit_baseline.rs b/crates/chanora_audio/examples/emit_baseline.rs index 4aeba74..16985cf 100644 --- a/crates/chanora_audio/examples/emit_baseline.rs +++ b/crates/chanora_audio/examples/emit_baseline.rs @@ -166,21 +166,18 @@ fn main() { "capture_callback_wall_clock" => read_estimate_ns(name), "opus_encode_latency" => read_estimate_ns(name), "opus_decode_latency" => read_estimate_ns(name), - "resampler_44100_to_48000" => read_estimate_group_ns( - "resampler_throughput", - "44100_to_48000", - ) - .map(|ns| ns_to_throughput(ns, 44_100.0)), - "resampler_16000_to_48000" => read_estimate_group_ns( - "resampler_throughput", - "16000_to_48000", - ) - .map(|ns| ns_to_throughput(ns, 16_000.0)), - "resampler_48000_passthrough" => read_estimate_group_ns( - "resampler_throughput", - "48000_passthrough", - ) - .map(|ns| ns_to_throughput(ns, 48_000.0)), + "resampler_44100_to_48000" => { + read_estimate_group_ns("resampler_throughput", "44100_to_48000") + .map(|ns| ns_to_throughput(ns, 44_100.0)) + } + "resampler_16000_to_48000" => { + read_estimate_group_ns("resampler_throughput", "16000_to_48000") + .map(|ns| ns_to_throughput(ns, 16_000.0)) + } + "resampler_48000_passthrough" => { + read_estimate_group_ns("resampler_throughput", "48000_passthrough") + .map(|ns| ns_to_throughput(ns, 48_000.0)) + } _ => None, }; let v = value.unwrap_or(f64::NAN); @@ -201,7 +198,6 @@ fn main() { }); let out_path = Path::new("current.json"); - fs::write(out_path, serde_json::to_string_pretty(&doc).unwrap()) - .expect("write current.json"); + fs::write(out_path, serde_json::to_string_pretty(&doc).unwrap()).expect("write current.json"); eprintln!("emit_baseline: wrote {}", out_path.display()); } diff --git a/crates/chanora_audio/src/android_voice_unit.rs b/crates/chanora_audio/src/android_voice_unit.rs index 63b2ac2..3fe6025 100644 --- a/crates/chanora_audio/src/android_voice_unit.rs +++ b/crates/chanora_audio/src/android_voice_unit.rs @@ -36,11 +36,17 @@ //! engine-state mutation happens off the audio thread (SDD-115). #![cfg(target_os = "android")] -#![allow(dead_code)] use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; -use tracing::{info, warn}; +use audiopus::coder::Encoder as OpusEncoder; +use audiopus::{ + Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels, + SampleRate as OpusSampleRate, +}; +use tracing::{debug, info, warn}; use crate::mobile_voice_backend::{ clear_android_audio_diagnostics, latency_tier_for, next_input_preset_after, @@ -50,6 +56,10 @@ use crate::mobile_voice_backend::{ BackendEventTx, EffectEngagement, EffectEngine, InputPresetChoice, MobileVoiceAudioBackend, SharingModeChoice, }; +use chanora_protocol::{AudioData, CodecType, OutAudio, OutPacket}; +use tsclientlib::audio::AudioHandler; + +use crate::{engine::SessionAudioId, AudioError}; use tokio::sync::mpsc; @@ -64,21 +74,127 @@ use oboe::{ // `mobile_voice_backend` so the trait can expose `take_event_rx` // (SDD-111 item 1) cross-platform. -// --- Empty I/O callbacks for the lifecycle skeleton (SDD-111) ---- +/// 20 ms at 48 kHz mono — one Opus frame's worth of samples. +/// Matches the iOS and desktop constants; duplicated here so this +/// module is fully self-contained and cfg-gate-clean. +const FRAME_SAMPLES: usize = 960; + +/// Maximum size of an encoded Opus frame in bytes (RFC 6716 §3.2.1). +const MAX_OPUS_FRAME: usize = 1275; + +// --- Capture state for Oboe input callback (SDD-111 / SDD-120) ---- // -// Audio data is plumbed through the existing engine paths -// (cpal-shaped channels feeding the `AudioHandler` mix). The -// callbacks here exist to (a) satisfy `oboe-rs`'s requirement that -// each async stream have a callback, and (b) provide the seam where -// the engine can later inject its capture / playback ring buffers. -// They are deliberately panic-free: any error path logs through the -// `tracing` macro and returns `DataCallbackResult::Continue`. A -// disconnect / error is delivered out-of-band through the error -// callback that `AudioStreamBuilder::set_error_callback` would -// install (the safe wrapper exposes this via the callback's -// `on_error_*` hooks). +// Mirrors the iOS `IosCaptureState` and the cpal-side `CaptureState`. +// Oboe delivers 48 kHz mono i16 PCM; we apply mic gain, accumulate to +// FRAME_SAMPLES, encode to Opus 32 kbps (complexity 10, inband FEC, 5 % PLC), +// and try-send the resulting packet on `voice_out_tx`. + +struct AndroidCaptureState { + encoder: OpusEncoder, + /// Accumulator for 48 kHz mono PCM. 2x capacity to absorb + /// cpal-style buffer-size jitter without reallocating. + pcm_accum: Vec, + opus_out: [u8; MAX_OPUS_FRAME], + voice_out_tx: mpsc::Sender, + transmit_active: Arc, + frames_sent: Arc, + mic_gain: f32, +} + +impl AndroidCaptureState { + fn new( + voice_out_tx: mpsc::Sender, + transmit_active: Arc, + frames_sent: Arc, + mic_gain: f32, + ) -> Result { + let mut encoder = + OpusEncoder::new(OpusSampleRate::Hz48000, OpusChannels::Mono, OpusApp::Voip) + .map_err(|e| AudioError::Opus(format!("encoder new (android): {e}")))?; + if let Err(e) = encoder.set_bitrate(audiopus::Bitrate::BitsPerSecond(32_000)) { + warn!(target: "chanora_audio", error = %e, "opus(android): set_bitrate(32000) failed"); + } + if let Err(e) = encoder.set_complexity(10) { + warn!(target: "chanora_audio", error = %e, "opus(android): set_complexity(10) failed"); + } + if let Err(e) = encoder.set_inband_fec(true) { + warn!(target: "chanora_audio", error = %e, "opus(android): set_inband_fec(true) failed"); + } + if let Err(e) = encoder.set_packet_loss_perc(5) { + warn!(target: "chanora_audio", error = %e, "opus(android): set_packet_loss_perc(5) failed"); + } + info!( + target: "chanora_audio", + bitrate_bps = 32_000, + complexity = 10, + inband_fec = true, + packet_loss_perc = 5, + "android Oboe opus encoder tuned for VoIP" + ); + Ok(Self { + encoder, + pcm_accum: Vec::with_capacity(FRAME_SAMPLES * 2), + opus_out: [0u8; MAX_OPUS_FRAME], + voice_out_tx, + transmit_active, + frames_sent, + mic_gain, + }) + } + + /// Consume i16 mono frames from Oboe, accumulate to FRAME_SAMPLES, + /// encode + send when PTT is held. Oboe delivers at the device's + /// native sample rate (always 48 kHz for modern Android per SRS-210), + /// so no resampling is needed. + fn ingest(&mut self, samples: &[i16]) { + if !self.transmit_active.load(Ordering::Relaxed) { + self.pcm_accum.clear(); + return; + } + // Mic-gain application. + if (self.mic_gain - 1.0).abs() < f32::EPSILON { + self.pcm_accum.extend_from_slice(samples); + } else { + let gain = self.mic_gain; + self.pcm_accum.extend(samples.iter().map(|&s| { + let scaled = (s as f32) * gain; + scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16 + })); + } + // Drain complete 20 ms frames. + while self.pcm_accum.len() >= FRAME_SAMPLES { + let mut frame = [0i16; FRAME_SAMPLES]; + frame.copy_from_slice(&self.pcm_accum[..FRAME_SAMPLES]); + self.pcm_accum.drain(..FRAME_SAMPLES); + match self.encoder.encode(&frame, &mut self.opus_out[..]) { + Ok(len) => { + let packet = OutAudio::new(&AudioData::C2S { + id: 0, + codec: CodecType::OpusVoice, + data: &self.opus_out[..len], + }); + match self.voice_out_tx.try_send(packet) { + Ok(()) => { + self.frames_sent.fetch_add(1, Ordering::Relaxed); + } + Err(mpsc::error::TrySendError::Full(_)) => { + warn!(target: "chanora_audio", "android Oboe: voice_out queue full; dropping frame"); + } + Err(mpsc::error::TrySendError::Closed(_)) => { + debug!(target: "chanora_audio", "android Oboe: voice_out closed; capture pipeline stopping"); + } + } + } + Err(e) => { + warn!(target: "chanora_audio", error = %e, "android Oboe opus encode failed"); + } + } + } + } +} struct InputCallback { + state: Arc>, event_tx: BackendEventTx, } @@ -88,36 +204,37 @@ impl AudioInputCallback for InputCallback { fn on_audio_ready( &mut self, _stream: &mut dyn AudioInputStreamSafe, - _frames: &[i16], + frames: &[i16], ) -> DataCallbackResult { - // Catch panics so a logic bug in the future can't abort the - // process under `panic=abort`. The audio thread MUST NOT - // panic. let _ = catch_unwind(AssertUnwindSafe(|| { - // Engine wires real capture through the AudioHandler - // path; this seam is intentionally a no-op for now. + if let Ok(mut s) = self.state.lock() { + s.ingest(frames); + } })); DataCallbackResult::Continue } fn on_error_after_close(&mut self, _stream: &mut dyn AudioInputStreamSafe, error: oboe::Error) { - // Oboe reports `ErrorDisconnected` here on route loss. - // We never call back into the engine from this method; - // instead we marshal a `Disconnected` event. if matches!(error, oboe::Error::Disconnected) { let _ = self.event_tx.send(BackendEvent::Disconnected); } else { - warn!( - target: "chanora_audio", - error = ?error, - "android: input stream error_after_close" - ); + warn!(target: "chanora_audio", error = ?error, "android: input stream error_after_close"); } } } +// --- Output callback wiring (SDD-111 / SDD-120) ---- +// +// Mirrors the iOS VPIO render callback. Pulls mixed 48 kHz stereo f32 +// from `AudioHandler::fill_buffer`, applies output gain + mute, and +// writes mono i16 to the Oboe output buffer. + struct OutputCallback { + handler: Arc>>, + output_gain: Arc, + output_muted: Arc, event_tx: BackendEventTx, + scratch: Arc>>, } impl AudioOutputCallback for OutputCallback { @@ -128,13 +245,45 @@ impl AudioOutputCallback for OutputCallback { _stream: &mut dyn AudioOutputStreamSafe, frames: &mut [i16], ) -> DataCallbackResult { - // Default to silence. The engine wires real playback through - // the existing AudioHandler path; this callback is a seam - // where a future commit replaces silence with a ring-buffer - // pull. Zeroing is panic-free and lock-free. let _ = catch_unwind(AssertUnwindSafe(|| { - for s in frames.iter_mut() { - *s = 0; + let needed = frames.len() * 2; // stereo + let scratch = &mut self.scratch.lock().unwrap(); + if scratch.len() < needed { + scratch.resize(needed, 0.0); + } else { + for s in &mut scratch[..needed] { + *s = 0.0; + } + } + // Non-blocking pull from AudioHandler (same pattern as iOS VPIO). + match self.handler.try_lock() { + Ok(mut h) => { + let _ = h.fill_buffer(&mut scratch[..needed]); + } + Err(std::sync::TryLockError::WouldBlock) => { + // scratch already zeroed above. + } + Err(std::sync::TryLockError::Poisoned(e)) => { + warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {}", e); + } + } + let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed)); + let muted = self.output_muted.load(Ordering::Relaxed); + let mut peak: i16 = 0; + for (i, dst) in frames.iter_mut().enumerate() { + if muted { + *dst = 0; + continue; + } + let l = scratch[i * 2]; + let r = scratch[i * 2 + 1]; + let mono = (l + r) * 0.5 * gain; + let clamped = mono.clamp(-1.0, 1.0); + let sample = (clamped * i16::MAX as f32) as i16; + *dst = sample; + if sample.unsigned_abs() > peak.unsigned_abs() { + peak = sample; + } } })); DataCallbackResult::Continue @@ -148,15 +297,34 @@ impl AudioOutputCallback for OutputCallback { if matches!(error, oboe::Error::Disconnected) { let _ = self.event_tx.send(BackendEvent::Disconnected); } else { - warn!( - target: "chanora_audio", - error = ?error, - "android: output stream error_after_close" - ); + warn!(target: "chanora_audio", error = ?error, "android: output stream error_after_close"); } } } +/// Bundle of engine-owned state shared with the Oboe audio callbacks. +/// Mirrors the parameter set that iOS `IosVoiceUnit::start()` receives +/// from the engine (SDD-120 amendment: Android Oboe-only audio path). +pub struct VoiceAudioParams { + /// Opus-encoded voice packets sent on this channel toward the + /// protocol layer. + pub voice_out_tx: mpsc::Sender, + /// PTT transmission gate — true when the user holds the PTT key. + pub transmit_active: Arc, + /// Counter incremented per encoded frame sent. + pub frames_sent: Arc, + /// Pre-encode amplitude scale (1.0 = unity). + pub mic_gain: f32, + /// AudioHandler that inbound解码+混合 feeds into; the Oboe output + /// callback pulls mixed stereo f32 from it. + pub handler: Arc>>, + /// Master output gain (f32 bits stored in AtomicU32 for lock-free + /// cross-thread read from the realtime audio callback). + pub output_gain: Arc, + /// True = output silence regardless of incoming voice frames. + pub output_muted: Arc, +} + // --- The backend itself ------------------------------------------ /// Android voice-audio backend (SDD-111). Owns one input + one @@ -199,9 +367,35 @@ impl AndroidVoiceUnit { /// effects. The engine is expected to have already issued /// `setMode(MODE_IN_COMMUNICATION)` (SDD-108) per the SDD-115 /// sequencing rules. - pub fn open(cfg: &AndroidVoiceStreamConfig) -> Result { + /// + /// `params` bundles the engine-owned state shared with the Oboe + /// audio callbacks (SDD-120 amendment: Android Oboe-only audio + /// path — capture pipeline, playback pull, and PTT gate). + #[allow(clippy::too_many_arguments)] + pub fn open( + cfg: &AndroidVoiceStreamConfig, + params: VoiceAudioParams, + ) -> Result { let (event_tx, event_rx) = mpsc::unbounded_channel(); + // SDD-120: build the capture state that the Oboe input callback + // will own via Arc. Same Opus VoIP tuning as iOS and + // desktop (32 kbps, complexity 10, inband FEC, 5 % PLC). + let capture_state = Arc::new(Mutex::new( + AndroidCaptureState::new( + params.voice_out_tx, + params.transmit_active, + params.frames_sent, + params.mic_gain, + ) + .map_err(|e| BackendError::OpenFailed(format!("capture state init: {e}")))?, + )); + + // Scratch buffer for the output callback (realtime-safe + // pre-allocation). 8192 floats covers the largest practical + // burst size at 48 kHz with headroom. + let scratch = Arc::new(Mutex::new(Vec::with_capacity(8192))); + // --- Open input stream (SDD-112) --------------------------- let mut input_builder = AudioStreamBuilder::default() .set_direction::() @@ -226,6 +420,7 @@ impl AndroidVoiceUnit { .set_usage(Usage::VoiceCommunication); let input_cb = InputCallback { + state: capture_state.clone(), event_tx: event_tx.clone(), }; let input_builder = input_builder.set_callback(input_cb); @@ -242,7 +437,7 @@ impl AndroidVoiceUnit { error = ?e, "android: primary input stream open failed; entering fallback ladder" ); - Self::open_input_fallback(cfg, &event_tx)? + Self::open_input_fallback(cfg, &event_tx, capture_state.clone())? } }; @@ -279,7 +474,11 @@ impl AndroidVoiceUnit { .set_content_type(oboe::ContentType::Speech); let output_cb = OutputCallback { + handler: params.handler.clone(), + output_gain: params.output_gain.clone(), + output_muted: params.output_muted.clone(), event_tx: event_tx.clone(), + scratch: scratch.clone(), }; let output_builder = output_builder.set_callback(output_cb); @@ -291,7 +490,14 @@ impl AndroidVoiceUnit { error = ?e, "android: primary output stream open failed; retrying with Shared sharing mode" ); - Self::open_output_fallback(cfg, &event_tx)? + Self::open_output_fallback( + cfg, + &event_tx, + params.handler.clone(), + params.output_gain.clone(), + params.output_muted.clone(), + scratch.clone(), + )? } }; @@ -390,6 +596,7 @@ impl AndroidVoiceUnit { fn open_input_fallback( cfg: &AndroidVoiceStreamConfig, event_tx: &BackendEventTx, + capture_state: Arc>, ) -> Result, BackendError> { // SDD-112 items 6 & 7: explore (preset × sharing) independently // via the pure helpers in `mobile_voice_backend`. Primary @@ -425,6 +632,7 @@ impl AndroidVoiceUnit { SharingModeChoice::Shared => SharingMode::Shared, }; let cb = InputCallback { + state: capture_state.clone(), event_tx: event_tx.clone(), }; let builder = AudioStreamBuilder::default() @@ -460,9 +668,17 @@ impl AndroidVoiceUnit { fn open_output_fallback( cfg: &AndroidVoiceStreamConfig, event_tx: &BackendEventTx, + handler: Arc>>, + output_gain: Arc, + output_muted: Arc, + scratch: Arc>>, ) -> Result, BackendError> { let cb = OutputCallback { + handler, + output_gain, + output_muted, event_tx: event_tx.clone(), + scratch, }; let builder = AudioStreamBuilder::default() .set_direction::() @@ -648,7 +864,9 @@ fn attach_hardware_effects( // SDD-115 callback safety: even on the (assumed) non-realtime // open/close paths, wrap the JNI body in `catch_unwind` so a // panic during teardown cannot unwind into the JVM. - let result = catch_unwind(AssertUnwindSafe(|| attach_hardware_effects_inner(session_id, effects))); + let result = catch_unwind(AssertUnwindSafe(|| { + attach_hardware_effects_inner(session_id, effects) + })); match result { Ok(h) => h, Err(_) => { @@ -690,13 +908,28 @@ fn attach_hardware_effects_inner( let mut handles = HardwareEffectHandles::default(); if effects.aec { - handles.aec = create_effect(&mut env, "android/media/audiofx/AcousticEchoCanceler", session_id, "AEC"); + handles.aec = create_effect( + &mut env, + "android/media/audiofx/AcousticEchoCanceler", + session_id, + "AEC", + ); } if effects.noise_suppression { - handles.ns = create_effect(&mut env, "android/media/audiofx/NoiseSuppressor", session_id, "NS"); + handles.ns = create_effect( + &mut env, + "android/media/audiofx/NoiseSuppressor", + session_id, + "NS", + ); } if effects.agc { - handles.agc = create_effect(&mut env, "android/media/audiofx/AutomaticGainControl", session_id, "AGC"); + handles.agc = create_effect( + &mut env, + "android/media/audiofx/AutomaticGainControl", + session_id, + "AGC", + ); } handles } diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs index 4aec3a5..aff5831 100644 --- a/crates/chanora_audio/src/engine.rs +++ b/crates/chanora_audio/src/engine.rs @@ -8,9 +8,9 @@ use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::{Arc, Mutex}; -#[cfg(not(target_os = "ios"))] +#[cfg(all(not(target_os = "ios"), not(target_os = "android")))] use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; -#[cfg(not(target_os = "ios"))] +#[cfg(all(not(target_os = "ios"), not(target_os = "android")))] use cpal::{SampleFormat, SizedSample}; use tokio::sync::mpsc; use tracing::{debug, info}; @@ -18,12 +18,15 @@ use tracing::{debug, info}; // playback paths (`build_input_stream`, `build_output_stream`, // `try_open_capture` log lines). Cfg-gate the imports too so iOS // builds don't carry an unused-imports warning. -#[cfg(not(target_os = "ios"))] +#[cfg(all(not(target_os = "ios"), not(target_os = "android")))] use tracing::{error, warn}; -#[cfg(not(target_os = "ios"))] +#[cfg(target_os = "android")] +use tracing::warn; + +#[cfg(all(not(target_os = "ios"), not(target_os = "android")))] use audiopus::coder::Encoder as OpusEncoder; -#[cfg(not(target_os = "ios"))] +#[cfg(all(not(target_os = "ios"), not(target_os = "android")))] use audiopus::{ Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels, SampleRate as OpusSampleRate, @@ -38,7 +41,7 @@ use tsclientlib::audio::AudioHandler; // iOS too, and `OutPacket` flows out of the capture pipeline once // commit 3 lands. Cfg-gate the cpal-only ones to keep iOS warnings // clean. -#[cfg(not(target_os = "ios"))] +#[cfg(all(not(target_os = "ios"), not(target_os = "android")))] use chanora_protocol::{AudioData, CodecType, OutAudio}; use chanora_protocol::{InboundVoice, OutPacket}; @@ -136,11 +139,15 @@ pub struct AudioEngine { // VoiceProcessingIO AudioUnit hosts mic + speaker) — cpal is // unused on iOS for the reasons documented in // `ios_voice_unit.rs`. - #[cfg(not(target_os = "ios"))] + #[cfg(all(not(target_os = "ios"), not(target_os = "android")))] _input_stream: Mutex>, #[cfg(target_os = "linux")] _output_stream: Mutex>, - #[cfg(all(not(target_os = "linux"), not(target_os = "ios")))] + #[cfg(all( + not(target_os = "linux"), + not(target_os = "ios"), + not(target_os = "android") + ))] _output_stream: Mutex>, #[cfg(target_os = "ios")] _ios_voice_unit: Mutex>, @@ -236,7 +243,11 @@ impl AudioEngine { { return Self::start_with_gate_ios(cfg, voice_out_tx, voice_in_rx, transmit_gate); } - #[cfg(not(target_os = "ios"))] + #[cfg(target_os = "android")] + { + return Self::start_with_gate_android(cfg, voice_out_tx, voice_in_rx, transmit_gate); + } + #[cfg(not(any(target_os = "ios", target_os = "android")))] { Self::start_with_gate_cpal(cfg, voice_out_tx, voice_in_rx, transmit_gate) } @@ -247,7 +258,7 @@ impl AudioEngine { /// at the top of `start_with_gate` without dragging a 200-line /// cfg-gated block. Body is the pre-iOS-port code, unchanged /// except for the new function name + signature. - #[cfg(not(target_os = "ios"))] + #[cfg(not(any(target_os = "ios", target_os = "android")))] fn start_with_gate_cpal( cfg: AudioEngineConfig, voice_out_tx: mpsc::Sender, @@ -313,153 +324,6 @@ impl AudioEngine { ), } - // SDD-115 lifecycle sequencing on engine start. Forward order: - // 1) bridge -> engine receives voice_join (here we are - // already inside `start`, the engine-side trigger). - // 2) start the Android voice foreground service so that - // the platform records the microphone capture under - // `foregroundServiceType="microphone"` (SDD-107 + SRS-215). - // 3) open the AAudio voice streams (SDD-111 + SDD-112). - // 4) engage `MODE_IN_COMMUNICATION` (SDD-108). - // 5) bind hardware effects (SDD-113) — performed inside - // `AndroidVoiceUnit::open()` once the input stream has a - // session id. - // The reverse order on engine drop is enforced by the field - // drop order (`_android_voice_unit` is dropped before the - // engine returns; `close()` is invoked from its Drop impl). - #[cfg(target_os = "android")] - let mut audio_mode_stack = crate::mode_stack::ModeStack::new(); - #[cfg(target_os = "android")] - let _android_voice_unit = { - if cfg.mobile_voice_preset { - // Step 2: foreground service. - if crate::android_voice_unit::chanora_android_start_voice_service() { - info!( - target: "chanora_audio", - "android: voice foreground service start dispatched (SDD-115)" - ); - } else { - warn!( - target: "chanora_audio", - "android: foreground service start failed; capture may be denied in background (SDD-115)" - ); - } - // Step 4 (mode engage) BEFORE Step 5 (effect bind); - // hardware-effect routing only engages reliably under - // MODE_IN_COMMUNICATION (SDD-113 item 6 / SDD-115). - // - // SDD-108 §1/§2: route through `ModeStack` so the - // 0 → 1 transition snapshots the prior platform mode - // (via `android_get_audio_mode`) and only that - // transition writes `MODE_IN_COMMUNICATION` via - // `android_set_audio_mode`. P0 only ever observes - // refcount {0, 1} per SRS-189 but the composition - // model is in place for P1. - match android_get_audio_mode() { - Ok(prior_now) => { - let outcome = audio_mode_stack.acquire(prior_now); - if let crate::mode_stack::ModeAcquire::FirstAcquire { prior } = outcome { - match android_set_audio_mode(ANDROID_MODE_IN_COMMUNICATION) { - Ok(()) => info!( - target: "chanora_audio", - prior_mode = prior, - "android: AudioManager mode set to MODE_IN_COMMUNICATION (SDD-108)" - ), - Err(e) => { - // SDD-108 §5: setMode failed AFTER - // the 0 → 1 ModeStack transition. - // Roll the stack back so refcount - // returns to 0 and the snapshot is - // cleared; otherwise a future - // release would issue an - // unmatched setMode(prior) against - // a system that never had its mode - // changed by us. - warn!( - target: "chanora_audio", - error = %e, - prior_mode = prior, - "android: setMode failed; rolling back ModeStack acquire (SDD-108 §5)" - ); - let _ = audio_mode_stack.release(); - } - } - } - } - Err(e) => warn!( - target: "chanora_audio", - error = %e, - "android: AudioManager.getMode failed; skipping mode engage (SDD-108)" - ), - } - // Step 3 + 5: open streams (SDD-111/112) and bind - // hardware effects (SDD-113). Failure here is logged - // and the engine continues with software AEC/NS/AGC - // via the existing engine path; the cpal data path - // remains the in-flight carrier. - // - // The prior silent-no-op log line at this site - // ("engagement depends on device AEC/NS support - // under MODE_IN_COMMUNICATION") is removed: the - // AndroidVoiceUnit either succeeds in engaging - // hardware effects (SDD-113) or logs the per-effect - // fallback, so engagement is now observable rather - // than rationalised. - let cfg_av = crate::mobile_voice_backend::AndroidVoiceStreamConfig { - effects: cfg.effects, - ..Default::default() - }; - match crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av) { - Ok(mut unit) => { - use crate::mobile_voice_backend::MobileVoiceAudioBackend; - if let Err(e) = unit.start() { - warn!( - target: "chanora_audio", - error = %e, - "android: AndroidVoiceUnit::start failed — cpal path remains active (SDD-115)" - ); - } - Some(unit) - } - Err(e) => { - warn!( - target: "chanora_audio", - error = %e, - "android: AndroidVoiceUnit::open failed — software AEC/NS/AGC fallback engages (SDD-111/SDD-113)" - ); - None - } - } - } else { - None - } - }; - #[cfg(target_os = "ios")] - { - if cfg.mobile_voice_preset { - // iOS AVAudioSession configuration is performed - // Swift-side in `apps/chanora_flutter/ios/Runner/ - // AppDelegate.swift::application(_:didFinishLaunching\ - // WithOptions:)` BEFORE Flutter starts its audio - // pipeline. The category/mode set there - // (`.playAndRecord` + `.voiceChat`, - // `defaultToSpeaker | allowBluetooth | - // allowBluetoothA2DP`) is the recommended iOS shape - // for voice clients and engages on-device AEC / NS - // routing where supported. cpal's CoreAudio - // backend then opens its streams against that - // session and inherits the routing. Logging here - // just records that the engine-start path - // acknowledges the request; the actual session - // mutation lives in Swift because it must happen - // before Dart loads. - info!( - target: "chanora_audio", - "ios: voice-chat session mode requested — AVAudioSession configured in AppDelegate" - ); - } - } - let transmit_flag_for_capture = transmit_gate.flag_arc(); let frames_sent = Arc::new(AtomicU32::new(0)); let frames_received = Arc::new(AtomicU32::new(0)); @@ -644,9 +508,158 @@ impl AudioEngine { output_muted, _input_stream: Mutex::new(input_stream), _output_stream: Mutex::new(Some(output_stream)), - #[cfg(target_os = "android")] - _android_voice_unit: Mutex::new(_android_voice_unit), - #[cfg(target_os = "android")] + shutdown_tx: Some(shutdown_tx), + capture_active, + ptt_watchdog, + }) + } + + #[cfg(target_os = "android")] + fn start_with_gate_android( + cfg: AudioEngineConfig, + voice_out_tx: mpsc::Sender, + mut voice_in_rx: mpsc::Receiver, + transmit_gate: crate::ptt::AudioTransmitGate, + ) -> Result { + use crate::mobile_voice_backend::{BackendEvent, MobileVoiceAudioBackend}; + + info!(target: "chanora_audio", "starting audio engine: Android Oboe backend"); + + let transmit_flag_for_capture = transmit_gate.flag_arc(); + let frames_sent = Arc::new(AtomicU32::new(0)); + let frames_received = Arc::new(AtomicU32::new(0)); + let output_gain = Arc::new(AtomicU32::new(1.0_f32.to_bits())); + let output_muted = Arc::new(AtomicBool::new(false)); + + let audio_handler: Arc>> = + Arc::new(Mutex::new(AudioHandler::new())); + + if !cfg.mobile_voice_preset { + return Err(AudioError::Backend( + "android: mobile_voice_preset=false is unsupported for Oboe-only backend" + .to_string(), + )); + } + + let mut audio_mode_stack = crate::mode_stack::ModeStack::new(); + + if crate::android_voice_unit::chanora_android_start_voice_service() { + info!( + target: "chanora_audio", + "android: voice foreground service start dispatched (SDD-115)" + ); + } else { + warn!( + target: "chanora_audio", + "android: foreground service start failed; capture may be denied in background (SDD-115)" + ); + } + + match android_get_audio_mode() { + Ok(prior_now) => { + let outcome = audio_mode_stack.acquire(prior_now); + if let crate::mode_stack::ModeAcquire::FirstAcquire { prior } = outcome { + match android_set_audio_mode(ANDROID_MODE_IN_COMMUNICATION) { + Ok(()) => info!( + target: "chanora_audio", + prior_mode = prior, + "android: AudioManager mode set to MODE_IN_COMMUNICATION (SDD-108)" + ), + Err(e) => { + warn!( + target: "chanora_audio", + error = %e, + prior_mode = prior, + "android: setMode failed; rolling back ModeStack acquire (SDD-108 §5)" + ); + let _ = audio_mode_stack.release(); + } + } + } + } + Err(e) => warn!( + target: "chanora_audio", + error = %e, + "android: AudioManager.getMode failed; skipping mode engage (SDD-108)" + ), + } + + let cfg_av = crate::mobile_voice_backend::AndroidVoiceStreamConfig { + effects: cfg.effects, + ..Default::default() + }; + let params = crate::android_voice_unit::VoiceAudioParams { + voice_out_tx, + transmit_active: transmit_flag_for_capture, + frames_sent: frames_sent.clone(), + mic_gain: cfg.mic_gain, + handler: audio_handler.clone(), + output_gain: output_gain.clone(), + output_muted: output_muted.clone(), + }; + let mut android_voice_unit = + crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av, params).map_err(|e| { + AudioError::Backend(format!("android: failed to open Oboe voice unit: {e}")) + })?; + + if let Err(e) = android_voice_unit.start() { + return Err(AudioError::Backend(format!( + "android: failed to start Oboe voice unit: {e}" + ))); + } + + if let Some(mut event_rx) = android_voice_unit.take_event_rx() { + tokio::spawn(async move { + while let Some(event) = event_rx.recv().await { + if let BackendEvent::Disconnected = event { + warn!( + target: "chanora_audio", + "android: backend disconnected event received; stream reconnect requires session restart" + ); + } + } + }); + } + + let capture_active = true; + + let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel(); + let handler_for_task = audio_handler.clone(); + let frames_received_for_task = frames_received.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + _ = &mut shutdown_rx => { + debug!(target: "chanora_audio", "inbound forwarder shutting down"); + break; + } + item = voice_in_rx.recv() => { + match item { + Some(v) => { + let id = SessionAudioId(v.from_client); + let mut h = handler_for_task.lock().unwrap(); + if let Err(e) = h.handle_packet(id, v.packet) { + debug!(target: "chanora_audio", error = %e, "decode failed"); + } else { + frames_received_for_task.fetch_add(1, Ordering::Relaxed); + } + } + None => break, + } + } + } + } + }); + + let ptt_watchdog: Option = None; + + Ok(Self { + transmit_gate, + frames_sent, + frames_received, + output_gain, + output_muted, + _android_voice_unit: Mutex::new(Some(android_voice_unit)), audio_mode_stack: Mutex::new(audio_mode_stack), shutdown_tx: Some(shutdown_tx), capture_active, @@ -783,7 +796,7 @@ impl AudioEngine { // audio. iOS collapses input + output into one // `IosVoiceUnit` (see `ios_voice_unit.rs`); every other // platform has separate cpal input + cpal/SDL output. - #[cfg(not(target_os = "ios"))] + #[cfg(all(not(target_os = "ios"), not(target_os = "android")))] { let _ = self._input_stream.lock().unwrap().take(); let _ = self._output_stream.lock().unwrap().take(); @@ -1023,7 +1036,7 @@ impl Drop for AudioEngine { // ---------- Capture pipeline ---------- -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] fn try_open_capture( in_dev: &cpal::Device, voice_out_tx: mpsc::Sender, @@ -1131,7 +1144,7 @@ fn try_open_capture( Ok(stream) } -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] struct CaptureState { encoder: OpusEncoder, in_sample_rate: u32, @@ -1169,7 +1182,7 @@ struct CaptureState { frame_scratch: Vec, } -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] impl CaptureState { fn new( encoder: OpusEncoder, @@ -1269,7 +1282,10 @@ impl CaptureState { *s = -1.0; } } - match self.encoder.encode_float(&frame[..], &mut self.opus_out[..]) { + match self + .encoder + .encode_float(&frame[..], &mut self.opus_out[..]) + { Ok(len) => { let packet = OutAudio::new(&AudioData::C2S { id: 0, @@ -1350,30 +1366,30 @@ impl CaptureState { } /// Per-sample format conversion to f32 in the range [-1.0, 1.0]. -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] trait ToF32 { fn to_f32_sample(self) -> f32; } -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] impl ToF32 for f32 { fn to_f32_sample(self) -> f32 { self } } -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] impl ToF32 for i16 { fn to_f32_sample(self) -> f32 { f32::from(self) / f32::from(i16::MAX) } } -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] impl ToF32 for u16 { fn to_f32_sample(self) -> f32 { (f32::from(self) - f32::from(i16::MAX) - 1.0) / f32::from(i16::MAX) } } -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] fn build_input_stream( device: &cpal::Device, config: &cpal::StreamConfig, @@ -1401,7 +1417,7 @@ where // ---------- Playback pipeline ---------- #[cfg(not(target_os = "linux"))] -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] fn build_output_stream( device: &cpal::Device, config: &cpal::StreamConfig, @@ -1590,7 +1606,7 @@ where /// Resampler state carried across output cpal callbacks. See /// `build_output_stream` for the rationale. #[cfg(not(target_os = "linux"))] -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] struct PlaybackResampleState { pos: f64, last_l: f32, @@ -1598,26 +1614,26 @@ struct PlaybackResampleState { } #[cfg(not(target_os = "linux"))] -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] trait FromF32 { fn from_f32_sample(v: f32) -> Self; } #[cfg(not(target_os = "linux"))] -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] impl FromF32 for f32 { fn from_f32_sample(v: f32) -> Self { v } } #[cfg(not(target_os = "linux"))] -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] impl FromF32 for i16 { fn from_f32_sample(v: f32) -> Self { (v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i16 } } #[cfg(not(target_os = "linux"))] -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] impl FromF32 for u16 { fn from_f32_sample(v: f32) -> Self { let s = (v.clamp(-1.0, 1.0) * f32::from(i16::MAX)) as i32; @@ -1720,12 +1736,12 @@ where let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) }; - let service_name: JString = env.new_string("audio").map_err(|e| { - AudioModeError::MethodCallFailed { - method: "new_string", - detail: e.to_string(), - } - })?; + let service_name: JString = + env.new_string("audio") + .map_err(|e| AudioModeError::MethodCallFailed { + method: "new_string", + detail: e.to_string(), + })?; let audio_manager = env .call_method( &context_obj, @@ -1809,11 +1825,11 @@ pub fn android_set_audio_mode(mode: i32) -> Result<(), AudioModeError> { // is not a supported external API. Only compiled on non-iOS targets // because `CaptureState` itself is gated on `cfg(not(target_os = "ios"))`. // --------------------------------------------------------------------------- -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] #[doc(hidden)] pub mod bench_seam { use super::{ - AtomicBool, AtomicU32, Arc, CaptureState, OpusApp, OpusChannels, OpusEncoder, + Arc, AtomicBool, AtomicU32, CaptureState, OpusApp, OpusChannels, OpusEncoder, OpusSampleRate, OutPacket, }; use tokio::sync::mpsc; @@ -1840,12 +1856,9 @@ pub mod bench_seam { /// resampler). `in_channels` selects the channel layout /// (typically 1 or 2). pub fn new(in_sample_rate: u32, in_channels: usize) -> Self { - let encoder = OpusEncoder::new( - OpusSampleRate::Hz48000, - OpusChannels::Mono, - OpusApp::Voip, - ) - .expect("opus encoder init"); + let encoder = + OpusEncoder::new(OpusSampleRate::Hz48000, OpusChannels::Mono, OpusApp::Voip) + .expect("opus encoder init"); let (tx, rx) = mpsc::channel::(64); let transmit_active = Arc::new(AtomicBool::new(true)); let frames_sent = Arc::new(AtomicU32::new(0)); diff --git a/crates/chanora_audio/src/lib.rs b/crates/chanora_audio/src/lib.rs index a4fb9e9..b73bda0 100644 --- a/crates/chanora_audio/src/lib.rs +++ b/crates/chanora_audio/src/lib.rs @@ -52,7 +52,7 @@ pub use engine::{AudioEngine, AudioEngineConfig}; // bench harness under `crates/chanora_audio/benches/` can construct a // CaptureState and drive `ingest` without re-implementing the engine. // Not part of the supported public API. -#[cfg(not(target_os = "ios"))] +#[cfg(not(any(target_os = "ios", target_os = "android")))] #[doc(hidden)] pub use engine::bench_seam; pub use ptt::{AudioTransmitGate, MissedKeyUpWatchdog, PttBackendDescriptor, PttCapabilityLevel}; @@ -84,6 +84,11 @@ pub enum AudioError { /// A backend-specific failure surfaced without a typed mapping. #[error("audio backend: {0}")] Backend(String), + /// Android platform not ready — ndk_context not initialised before + /// audio engine start. This occurs when `initChanoraContext` has not + /// been called before `voice_join` triggers the audio engine. + #[error("android platform not ready: ndk_context not initialised")] + PlatformNotReady, } /// Audio-effect toggles. Defaults match DEC-007 (AEC), diff --git a/crates/chanora_audio/src/mobile_voice_backend.rs b/crates/chanora_audio/src/mobile_voice_backend.rs index ca3983d..a132795 100644 --- a/crates/chanora_audio/src/mobile_voice_backend.rs +++ b/crates/chanora_audio/src/mobile_voice_backend.rs @@ -321,7 +321,8 @@ pub enum SharingModeChoice { /// Returns the next sharing-mode to try (SDD-112 item 7, /// SWE4-UV-049). Ladder: Exclusive -> Shared -> exhausted. pub fn next_sharing_mode_after(attempted: &[SharingModeChoice]) -> Option { - const LADDER: [SharingModeChoice; 2] = [SharingModeChoice::Exclusive, SharingModeChoice::Shared]; + const LADDER: [SharingModeChoice; 2] = + [SharingModeChoice::Exclusive, SharingModeChoice::Shared]; LADDER.iter().copied().find(|m| !attempted.contains(m)) } @@ -746,8 +747,14 @@ mod tests { /// on these tokens). #[test] fn swe4_uv_047_achieved_enum_display_is_stable() { - assert_eq!(format!("{}", AchievedPerformanceMode::LowLatency), "LowLatency"); - assert_eq!(format!("{}", AchievedPerformanceMode::PowerSaving), "PowerSaving"); + assert_eq!( + format!("{}", AchievedPerformanceMode::LowLatency), + "LowLatency" + ); + assert_eq!( + format!("{}", AchievedPerformanceMode::PowerSaving), + "PowerSaving" + ); assert_eq!(format!("{}", AchievedPerformanceMode::None), "None"); assert_eq!(format!("{}", AchievedSharingMode::Exclusive), "Exclusive"); assert_eq!(format!("{}", AchievedSharingMode::Shared), "Shared"); diff --git a/crates/chanora_audio/src/release_tail.rs b/crates/chanora_audio/src/release_tail.rs index eb7a0b5..ebf2146 100644 --- a/crates/chanora_audio/src/release_tail.rs +++ b/crates/chanora_audio/src/release_tail.rs @@ -8,8 +8,8 @@ //! cancelled. Drives [`AudioTransmitGate`] directly. use std::sync::atomic::{AtomicU32, Ordering}; -use std::time::Duration; use std::sync::Arc; +use std::time::Duration; use tokio::sync::watch; use tokio::task::JoinHandle; diff --git a/crates/chanora_audio/src/transmit_selector.rs b/crates/chanora_audio/src/transmit_selector.rs index 591d9c9..1ac76c9 100644 --- a/crates/chanora_audio/src/transmit_selector.rs +++ b/crates/chanora_audio/src/transmit_selector.rs @@ -152,7 +152,8 @@ impl TransmitModeSelector { /// permission revocation immediately silences the microphone /// even mid-PTT. pub fn set_permission_state(&self, state: PermissionGate) { - self.permission_state.store(state.as_u8(), Ordering::Relaxed); + self.permission_state + .store(state.as_u8(), Ordering::Relaxed); self.recompute(); } diff --git a/crates/chanora_bridge/src/api.rs b/crates/chanora_bridge/src/api.rs index 3d6910f..0166f85 100644 --- a/crates/chanora_bridge/src/api.rs +++ b/crates/chanora_bridge/src/api.rs @@ -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, + /// Non-authoritative pending join target channel id. + pending_target_channel_id: Option, + /// 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, }, /// 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 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 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 diff --git a/crates/chanora_bridge/src/frb_generated.rs b/crates/chanora_bridge/src/frb_generated.rs index f18c7d0..b03783b 100644 --- a/crates/chanora_bridge/src/frb_generated.rs +++ b/crates/chanora_bridge/src/frb_generated.rs @@ -1403,11 +1403,25 @@ impl SseDecode for crate::api::BridgeEvent { ::sse_decode(deserializer); let mut var_mute = ::sse_decode(deserializer); let mut var_releaseTailMs = ::sse_decode(deserializer); + let mut var_currentChannelId = >::sse_decode(deserializer); + let mut var_pendingTargetChannelId = >::sse_decode(deserializer); + let mut var_canJoin = ::sse_decode(deserializer); + let mut var_canLeave = ::sse_decode(deserializer); + let mut var_joinSyncState = + ::sse_decode(deserializer); + let mut var_joinErrorCode = + >::sse_decode(deserializer); return crate::api::BridgeEvent::VoiceState { in_channel: var_inChannel, transmit_mode: var_transmitMode, mute: var_mute, release_tail_ms: var_releaseTailMs, + current_channel_id: var_currentChannelId, + pending_target_channel_id: var_pendingTargetChannelId, + can_join: var_canJoin, + can_leave: var_canLeave, + join_sync_state: var_joinSyncState, + join_error_code: var_joinErrorCode, }; } 9 => { @@ -1494,6 +1508,40 @@ impl SseDecode for crate::api::BridgeTransmitMode { } } +impl SseDecode for crate::api::BridgeVoiceJoinErrorCode { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return match inner { + 0 => crate::api::BridgeVoiceJoinErrorCode::DuplicateSameTargetCoalesced, + 1 => crate::api::BridgeVoiceJoinErrorCode::JoinAlreadyPendingDifferentTarget, + 2 => crate::api::BridgeVoiceJoinErrorCode::JoinDenied, + 3 => crate::api::BridgeVoiceJoinErrorCode::JoinProtocolFailure, + 4 => crate::api::BridgeVoiceJoinErrorCode::JoinNetworkFailure, + 5 => crate::api::BridgeVoiceJoinErrorCode::JoinTimeout, + 6 => crate::api::BridgeVoiceJoinErrorCode::JoinSupersededByLeave, + 7 => crate::api::BridgeVoiceJoinErrorCode::JoinStaleOutcomeIgnored, + 8 => crate::api::BridgeVoiceJoinErrorCode::JoinReconciledDifferentChannel, + 9 => crate::api::BridgeVoiceJoinErrorCode::JoinCommandRejectedBeforeSend, + 10 => crate::api::BridgeVoiceJoinErrorCode::JoinCannotStartWhileSynchronizing, + _ => unreachable!("Invalid variant for BridgeVoiceJoinErrorCode: {}", inner), + }; + } +} + +impl SseDecode for crate::api::BridgeVoiceJoinSyncState { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return match inner { + 0 => crate::api::BridgeVoiceJoinSyncState::Ready, + 1 => crate::api::BridgeVoiceJoinSyncState::SynchronizingInitialSnapshot, + 2 => crate::api::BridgeVoiceJoinSyncState::SynchronizingReconnect, + _ => unreachable!("Invalid variant for BridgeVoiceJoinSyncState: {}", inner), + }; + } +} + impl SseDecode for f32 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -1563,6 +1611,30 @@ impl SseDecode for Vec { } } +impl SseDecode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + if (::sse_decode(deserializer)) { + return Some(::sse_decode( + deserializer, + )); + } else { + return None; + } + } +} + +impl SseDecode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + if (::sse_decode(deserializer)) { + return Some(::sse_decode(deserializer)); + } else { + return None; + } + } +} + impl SseDecode for crate::api::PermissionStateKind { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { @@ -1841,12 +1913,24 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent { transmit_mode, mute, release_tail_ms, + current_channel_id, + pending_target_channel_id, + can_join, + can_leave, + join_sync_state, + join_error_code, } => [ 8.into_dart(), in_channel.into_into_dart().into_dart(), transmit_mode.into_into_dart().into_dart(), mute.into_into_dart().into_dart(), release_tail_ms.into_into_dart().into_dart(), + current_channel_id.into_into_dart().into_dart(), + pending_target_channel_id.into_into_dart().into_dart(), + can_join.into_into_dart().into_dart(), + can_leave.into_into_dart().into_dart(), + join_sync_state.into_into_dart().into_dart(), + join_error_code.into_into_dart().into_dart(), ] .into_dart(), crate::api::BridgeEvent::InterruptionState { @@ -1964,6 +2048,58 @@ impl flutter_rust_bridge::IntoIntoDart } } // Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::BridgeVoiceJoinErrorCode { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + match self { + Self::DuplicateSameTargetCoalesced => 0.into_dart(), + Self::JoinAlreadyPendingDifferentTarget => 1.into_dart(), + Self::JoinDenied => 2.into_dart(), + Self::JoinProtocolFailure => 3.into_dart(), + Self::JoinNetworkFailure => 4.into_dart(), + Self::JoinTimeout => 5.into_dart(), + Self::JoinSupersededByLeave => 6.into_dart(), + Self::JoinStaleOutcomeIgnored => 7.into_dart(), + Self::JoinReconciledDifferentChannel => 8.into_dart(), + Self::JoinCommandRejectedBeforeSend => 9.into_dart(), + Self::JoinCannotStartWhileSynchronizing => 10.into_dart(), + _ => unreachable!(), + } + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::BridgeVoiceJoinErrorCode +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::BridgeVoiceJoinErrorCode +{ + fn into_into_dart(self) -> crate::api::BridgeVoiceJoinErrorCode { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::BridgeVoiceJoinSyncState { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + match self { + Self::Ready => 0.into_dart(), + Self::SynchronizingInitialSnapshot => 1.into_dart(), + Self::SynchronizingReconnect => 2.into_dart(), + _ => unreachable!(), + } + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::BridgeVoiceJoinSyncState +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::BridgeVoiceJoinSyncState +{ + fn into_into_dart(self) -> crate::api::BridgeVoiceJoinSyncState { + self + } +} +// Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::PermissionStateKind { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { match self { @@ -2146,12 +2282,27 @@ impl SseEncode for crate::api::BridgeEvent { transmit_mode, mute, release_tail_ms, + current_channel_id, + pending_target_channel_id, + can_join, + can_leave, + join_sync_state, + join_error_code, } => { ::sse_encode(8, serializer); ::sse_encode(in_channel, serializer); ::sse_encode(transmit_mode, serializer); ::sse_encode(mute, serializer); ::sse_encode(release_tail_ms, serializer); + >::sse_encode(current_channel_id, serializer); + >::sse_encode(pending_target_channel_id, serializer); + ::sse_encode(can_join, serializer); + ::sse_encode(can_leave, serializer); + ::sse_encode(join_sync_state, serializer); + >::sse_encode( + join_error_code, + serializer, + ); } crate::api::BridgeEvent::InterruptionState { began, @@ -2237,6 +2388,48 @@ impl SseEncode for crate::api::BridgeTransmitMode { } } +impl SseEncode for crate::api::BridgeVoiceJoinErrorCode { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode( + match self { + crate::api::BridgeVoiceJoinErrorCode::DuplicateSameTargetCoalesced => 0, + crate::api::BridgeVoiceJoinErrorCode::JoinAlreadyPendingDifferentTarget => 1, + crate::api::BridgeVoiceJoinErrorCode::JoinDenied => 2, + crate::api::BridgeVoiceJoinErrorCode::JoinProtocolFailure => 3, + crate::api::BridgeVoiceJoinErrorCode::JoinNetworkFailure => 4, + crate::api::BridgeVoiceJoinErrorCode::JoinTimeout => 5, + crate::api::BridgeVoiceJoinErrorCode::JoinSupersededByLeave => 6, + crate::api::BridgeVoiceJoinErrorCode::JoinStaleOutcomeIgnored => 7, + crate::api::BridgeVoiceJoinErrorCode::JoinReconciledDifferentChannel => 8, + crate::api::BridgeVoiceJoinErrorCode::JoinCommandRejectedBeforeSend => 9, + crate::api::BridgeVoiceJoinErrorCode::JoinCannotStartWhileSynchronizing => 10, + _ => { + unimplemented!(""); + } + }, + serializer, + ); + } +} + +impl SseEncode for crate::api::BridgeVoiceJoinSyncState { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode( + match self { + crate::api::BridgeVoiceJoinSyncState::Ready => 0, + crate::api::BridgeVoiceJoinSyncState::SynchronizingInitialSnapshot => 1, + crate::api::BridgeVoiceJoinSyncState::SynchronizingReconnect => 2, + _ => { + unimplemented!(""); + } + }, + serializer, + ); + } +} + impl SseEncode for f32 { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { @@ -2298,6 +2491,26 @@ impl SseEncode for Vec { } } +impl SseEncode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.is_some(), serializer); + if let Some(value) = self { + ::sse_encode(value, serializer); + } + } +} + +impl SseEncode for Option { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode(self.is_some(), serializer); + if let Some(value) = self { + ::sse_encode(value, serializer); + } + } +} + impl SseEncode for crate::api::PermissionStateKind { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { diff --git a/crates/chanora_state/src/channel_join.rs b/crates/chanora_state/src/channel_join.rs new file mode 100644 index 0000000..4834d42 --- /dev/null +++ b/crates/chanora_state/src/channel_join.rs @@ -0,0 +1,1061 @@ +//! Deterministic channel-join pending-state reducer. +//! +//! This module implements SDD-121 Phase A for the `chanora_state` crate: +//! Rust-owned authoritative current-channel membership, non-authoritative +//! pending join state, and reconnect/snapshot reconciliation. + +use std::time::Instant; + +/// Channel identifier at the reducer seam. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ChannelId(pub u64); + +/// Per-connection epoch used to reject stale outcomes and deltas. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ConnectionEpoch(pub u64); + +/// Monotonic join/leave generation within reducer state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct JoinGeneration(pub u64); + +/// Protocol request identifier unique within one connection epoch. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct JoinRequestId(pub u64); + +/// Server-confirmed voice-channel membership. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AuthoritativeMembership { + /// Last server-authoritative current channel, or none if not in voice. + pub current_channel: Option, + /// Epoch that produced the confirmed membership value. + pub confirmed_epoch: ConnectionEpoch, +} + +/// Active non-authoritative join intent awaiting server confirmation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct JoinPending { + /// Requested target channel. + pub target_channel: ChannelId, + /// Previous confirmed channel to preserve on failure/timeout. + pub previous_confirmed_channel: Option, + /// Protocol request id after command-send acceptance. + pub request_id: Option, + /// Reducer generation for this intent. + pub generation: JoinGeneration, + /// Deterministic start time supplied by the caller. + pub started_at: Instant, + /// Connection epoch in which the intent was created. + pub connection_epoch: ConnectionEpoch, +} + +/// Reducer-owned channel-join state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChannelJoinState { + /// Server-authoritative membership. + pub authoritative: AuthoritativeMembership, + /// Active pending join, if any. + pub pending: Option, + /// Current snapshot/reconnect synchronization state. + pub sync_state: ChannelJoinSyncState, + /// Next generation to allocate. + pub next_generation: JoinGeneration, + /// Last sanitized join error for projection consumers. + pub last_join_error: Option, +} + +impl ChannelJoinState { + /// Create channel-join reducer state for a connection epoch. + pub fn new(epoch: ConnectionEpoch) -> Self { + Self { + authoritative: AuthoritativeMembership { + current_channel: None, + confirmed_epoch: epoch, + }, + pending: None, + sync_state: ChannelJoinSyncState::Ready, + next_generation: JoinGeneration(1), + last_join_error: None, + } + } + + fn allocate_generation(&mut self) -> JoinGeneration { + let generation = self.next_generation; + self.next_generation = JoinGeneration(self.next_generation.0.saturating_add(1)); + generation + } + + fn bump_generation(&mut self) { + self.next_generation = JoinGeneration(self.next_generation.0.saturating_add(1)); + } + + fn current_epoch(&self) -> ConnectionEpoch { + match self.sync_state { + ChannelJoinSyncState::Ready => self.authoritative.confirmed_epoch, + ChannelJoinSyncState::Synchronizing { epoch, .. } => epoch, + } + } +} + +/// State-sync readiness for channel actions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChannelJoinSyncState { + /// Reducer is ready to accept channel actions. + Ready, + /// Reducer is waiting for a snapshot boundary. + Synchronizing { + /// Reason for synchronization. + reason: SyncReason, + /// Epoch being synchronized. + epoch: ConnectionEpoch, + }, +} + +/// Reason channel-join state is synchronizing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyncReason { + /// Initial snapshot is in progress. + InitialSnapshot, + /// Reconnect snapshot is in progress. + Reconnect, +} + +/// Correlation key for protocol outcomes and timeout callbacks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct JoinOutcomeKey { + /// Connection epoch of the pending join. + pub connection_epoch: ConnectionEpoch, + /// Generation of the pending join. + pub generation: JoinGeneration, + /// Protocol request id, once accepted for send. + pub request_id: Option, +} + +/// Reducer projection consumed by core/bridge mapping. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChannelJoinProjection { + /// Last confirmed current channel. + pub current_channel: Option, + /// Non-authoritative pending target channel. + pub pending_target: Option, + /// Start time of the active pending join. + pub pending_since: Option, + /// Generation of the active pending join. + pub pending_generation: Option, + /// Whether a new join can be initiated. + pub can_join: bool, + /// Whether leave is possible for current/pending state. + pub can_leave: bool, + /// Current sync state. + pub sync_state: ChannelJoinSyncState, + /// Last sanitized join error. + pub last_join_error: Option, +} + +/// Source of authoritative membership input. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthoritativeSource { + /// Live protocol delta. + LiveDelta, + /// Snapshot input. + Snapshot, +} + +/// Events accepted by the channel-join reducer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChannelJoinEvent { + /// User requested a channel join. + UserJoinRequested { + /// Requested target channel. + target_channel: ChannelId, + /// Caller-supplied deterministic request time. + now: Instant, + }, + /// Protocol command send accepted and produced a request id. + JoinCommandAccepted { + /// Pending generation accepted by the command path. + generation: JoinGeneration, + /// Protocol request id for future outcomes. + request_id: JoinRequestId, + }, + /// Protocol command failed before a request id existed. + JoinCommandRejectedBeforeSend { + /// Pending generation rejected by the command path. + generation: JoinGeneration, + /// Sanitized failure kind. + error: JoinFailureKind, + }, + /// Protocol command reported success; not authoritative membership. + ProtocolJoinSucceeded { + /// Outcome correlation key. + key: JoinOutcomeKey, + }, + /// Protocol command reported failure. + ProtocolJoinFailed { + /// Outcome correlation key. + key: JoinOutcomeKey, + /// Sanitized failure kind. + failure: JoinFailureKind, + }, + /// Join command timed out. + JoinTimeout { + /// Outcome correlation key. + key: JoinOutcomeKey, + /// Caller-supplied deterministic timeout time. + now: Instant, + }, + /// Server-authoritative self membership changed. + AuthoritativeSelfMove { + /// Server-authoritative channel value. + channel: Option, + /// Epoch of the live delta or snapshot source. + epoch: ConnectionEpoch, + /// Authoritative input source. + source: AuthoritativeSource, + }, + /// User requested leave. + UserLeaveRequested { + /// Caller-supplied deterministic leave time. + now: Instant, + }, + /// Reconnect started. + ReconnectStarted { + /// New connection epoch. + new_epoch: ConnectionEpoch, + /// Caller-supplied deterministic reconnect time. + now: Instant, + }, + /// Fresh snapshot is ready. + SnapshotReady { + /// Snapshot current channel value. + current_channel: Option, + /// Snapshot epoch. + epoch: ConnectionEpoch, + /// Caller-supplied deterministic snapshot time. + now: Instant, + }, +} + +/// Side-effect actions returned by the reducer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChannelJoinAction { + /// Send a protocol join command. + SendJoinCommand { + /// Requested target channel. + target_channel: ChannelId, + /// Generation associated with the command. + generation: JoinGeneration, + /// Connection epoch associated with the command. + epoch: ConnectionEpoch, + }, + /// Start a deterministic join timeout. + StartJoinTimeout { + /// Outcome key the timeout will report. + key: JoinOutcomeKey, + /// Timeout start time. + started_at: Instant, + }, + /// Cancel a deterministic join timeout. + CancelJoinTimeout { + /// Outcome key to cancel. + key: JoinOutcomeKey, + }, + /// Send a protocol leave command. + SendLeaveCommand { + /// Current authoritative channel at leave request time. + current_channel: Option, + /// Generation associated with the leave command. + generation: JoinGeneration, + /// Connection epoch associated with the leave command. + epoch: ConnectionEpoch, + }, + /// Publish the latest projection. + PublishProjection(ChannelJoinProjection), + /// Emit a sanitized diagnostic. + EmitDiagnostic { + /// Sanitized diagnostic key. + key: JoinDiagnosticKey, + /// Optional stable error code. + code: Option, + }, +} + +/// Sanitized diagnostic event key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JoinDiagnosticKey { + /// Join was requested. + JoinRequested, + /// Join command was accepted for send. + JoinCommandSent, + /// Duplicate target was coalesced. + JoinDuplicateCoalesced, + /// Join was confirmed authoritatively. + JoinConfirmed, + /// Join failed. + JoinFailed, + /// Join timed out. + JoinTimeout, + /// Stale outcome ignored. + JoinStaleOutcomeIgnored, + /// Pending join superseded by leave. + JoinPendingSupersededByLeave, + /// Reconnect synchronization started. + JoinReconnectSynchronizing, + /// Snapshot reconciled membership. + JoinSnapshotReconciled, +} + +/// Status for a reducer transition. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JoinReduceStatus { + /// Event was accepted. + Accepted, + /// Duplicate same-target join was coalesced. + CoalescedSameTarget, + /// Intent was rejected. + Rejected(JoinIntentRejected), + /// Stale outcome was ignored. + StaleOutcomeIgnored, + /// Join was authoritatively confirmed. + Confirmed, + /// Join failed. + Failed(JoinFailureKind), + /// Join timed out. + TimedOut, + /// Join was superseded by leave. + SupersededByLeave, + /// State was reconciled by snapshot. + ReconciledBySnapshot, +} + +/// User intent rejection reason. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JoinIntentRejected { + /// A different target is already pending. + JoinAlreadyPendingDifferentTarget, + /// Joins cannot start while synchronizing. + CannotJoinWhileSynchronizing, +} + +/// Coarse failure kind without raw protocol payloads. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JoinFailureKind { + /// Server denied the join. + Denied, + /// Network failure. + Network, + /// Protocol failure. + Protocol, + /// Timeout. + Timeout, + /// Unknown sanitized failure. + Unknown, +} + +/// Stable sanitized error code consumed by bridge/UI mapping. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JoinErrorCode { + /// Duplicate same target was coalesced. + DuplicateSameTargetCoalesced, + /// Different target requested while pending. + JoinAlreadyPendingDifferentTarget, + /// Join was denied. + JoinDenied, + /// Protocol failure. + JoinProtocolFailure, + /// Network failure. + JoinNetworkFailure, + /// Join timed out. + JoinTimeout, + /// Join superseded by leave. + JoinSupersededByLeave, + /// Stale outcome ignored. + JoinStaleOutcomeIgnored, + /// Snapshot/live delta reconciled to a different channel. + JoinReconciledDifferentChannel, + /// Command rejected before send. + JoinCommandRejectedBeforeSend, + /// Cannot start while synchronizing. + JoinCannotStartWhileSynchronizing, +} + +/// Complete reducer result. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChannelJoinReduction { + /// Transition status. + pub status: JoinReduceStatus, + /// Projection after reduction. + pub projection: ChannelJoinProjection, + /// Side effects for the owning runtime. + pub actions: Vec, +} + +/// Reduce one channel-join event into state and side-effect actions. +pub fn reduce(state: &mut ChannelJoinState, event: ChannelJoinEvent) -> ChannelJoinReduction { + let mut actions = Vec::new(); + let status = match event { + ChannelJoinEvent::UserJoinRequested { + target_channel, + now, + } => { + if matches!(state.sync_state, ChannelJoinSyncState::Synchronizing { .. }) { + state.last_join_error = Some(JoinErrorCode::JoinCannotStartWhileSynchronizing); + actions.push(ChannelJoinAction::EmitDiagnostic { + key: JoinDiagnosticKey::JoinFailed, + code: state.last_join_error, + }); + JoinReduceStatus::Rejected(JoinIntentRejected::CannotJoinWhileSynchronizing) + } else if let Some(pending) = state.pending { + if pending.target_channel == target_channel { + state.last_join_error = Some(JoinErrorCode::DuplicateSameTargetCoalesced); + actions.push(ChannelJoinAction::EmitDiagnostic { + key: JoinDiagnosticKey::JoinDuplicateCoalesced, + code: state.last_join_error, + }); + JoinReduceStatus::CoalescedSameTarget + } else { + state.last_join_error = Some(JoinErrorCode::JoinAlreadyPendingDifferentTarget); + actions.push(ChannelJoinAction::EmitDiagnostic { + key: JoinDiagnosticKey::JoinFailed, + code: state.last_join_error, + }); + JoinReduceStatus::Rejected( + JoinIntentRejected::JoinAlreadyPendingDifferentTarget, + ) + } + } else { + let generation = state.allocate_generation(); + let epoch = state.current_epoch(); + state.pending = Some(JoinPending { + target_channel, + previous_confirmed_channel: state.authoritative.current_channel, + request_id: None, + generation, + started_at: now, + connection_epoch: epoch, + }); + state.last_join_error = None; + let key = JoinOutcomeKey { + connection_epoch: epoch, + generation, + request_id: None, + }; + actions.push(ChannelJoinAction::SendJoinCommand { + target_channel, + generation, + epoch, + }); + actions.push(ChannelJoinAction::StartJoinTimeout { + key, + started_at: now, + }); + actions.push(ChannelJoinAction::EmitDiagnostic { + key: JoinDiagnosticKey::JoinRequested, + code: None, + }); + JoinReduceStatus::Accepted + } + } + ChannelJoinEvent::JoinCommandAccepted { + generation, + request_id, + } => { + if let Some(pending) = state.pending.as_mut() { + if pending.generation == generation { + let old_key = pending_key(pending); + pending.request_id = Some(request_id); + let new_key = pending_key(pending); + actions.push(ChannelJoinAction::CancelJoinTimeout { key: old_key }); + actions.push(ChannelJoinAction::StartJoinTimeout { + key: new_key, + started_at: pending.started_at, + }); + actions.push(ChannelJoinAction::EmitDiagnostic { + key: JoinDiagnosticKey::JoinCommandSent, + code: None, + }); + JoinReduceStatus::Accepted + } else { + stale(state, &mut actions) + } + } else { + stale(state, &mut actions) + } + } + ChannelJoinEvent::JoinCommandRejectedBeforeSend { generation, error } => { + if let Some(pending) = state.pending { + if pending.generation == generation { + let key = pending_key(&pending); + state.authoritative.current_channel = pending.previous_confirmed_channel; + state.pending = None; + state.last_join_error = Some(JoinErrorCode::JoinCommandRejectedBeforeSend); + actions.push(ChannelJoinAction::CancelJoinTimeout { key }); + actions.push(ChannelJoinAction::EmitDiagnostic { + key: JoinDiagnosticKey::JoinFailed, + code: state.last_join_error, + }); + JoinReduceStatus::Failed(error) + } else { + stale(state, &mut actions) + } + } else { + stale(state, &mut actions) + } + } + ChannelJoinEvent::ProtocolJoinSucceeded { key } => { + if key_matches(state.pending, key) { + JoinReduceStatus::Accepted + } else { + stale(state, &mut actions) + } + } + ChannelJoinEvent::ProtocolJoinFailed { key, failure } => { + if key_matches(state.pending, key) { + let pending = state.pending.expect("key match requires pending"); + state.authoritative.current_channel = pending.previous_confirmed_channel; + state.pending = None; + state.last_join_error = Some(failure_error_code(failure)); + actions.push(ChannelJoinAction::CancelJoinTimeout { key }); + actions.push(ChannelJoinAction::EmitDiagnostic { + key: JoinDiagnosticKey::JoinFailed, + code: state.last_join_error, + }); + JoinReduceStatus::Failed(failure) + } else { + stale(state, &mut actions) + } + } + ChannelJoinEvent::JoinTimeout { key, now: _ } => { + if key_matches(state.pending, key) { + let pending = state.pending.expect("key match requires pending"); + state.authoritative.current_channel = pending.previous_confirmed_channel; + state.pending = None; + state.last_join_error = Some(JoinErrorCode::JoinTimeout); + actions.push(ChannelJoinAction::CancelJoinTimeout { key }); + actions.push(ChannelJoinAction::EmitDiagnostic { + key: JoinDiagnosticKey::JoinTimeout, + code: state.last_join_error, + }); + JoinReduceStatus::TimedOut + } else { + stale(state, &mut actions) + } + } + ChannelJoinEvent::AuthoritativeSelfMove { + channel, + epoch, + source: _, + } => { + if epoch != state.current_epoch() { + stale(state, &mut actions) + } else { + state.authoritative.current_channel = channel; + state.authoritative.confirmed_epoch = epoch; + if let Some(pending) = state.pending { + let key = pending_key(&pending); + state.pending = None; + actions.push(ChannelJoinAction::CancelJoinTimeout { key }); + if channel == Some(pending.target_channel) { + state.last_join_error = None; + actions.push(ChannelJoinAction::EmitDiagnostic { + key: JoinDiagnosticKey::JoinConfirmed, + code: None, + }); + JoinReduceStatus::Confirmed + } else { + state.last_join_error = Some(JoinErrorCode::JoinReconciledDifferentChannel); + actions.push(ChannelJoinAction::EmitDiagnostic { + key: JoinDiagnosticKey::JoinSnapshotReconciled, + code: state.last_join_error, + }); + JoinReduceStatus::ReconciledBySnapshot + } + } else { + JoinReduceStatus::Accepted + } + } + } + ChannelJoinEvent::UserLeaveRequested { now: _ } => { + let generation = state.allocate_generation(); + let epoch = state.current_epoch(); + if let Some(pending) = state.pending { + let key = pending_key(&pending); + state.pending = None; + state.last_join_error = Some(JoinErrorCode::JoinSupersededByLeave); + actions.push(ChannelJoinAction::CancelJoinTimeout { key }); + actions.push(ChannelJoinAction::EmitDiagnostic { + key: JoinDiagnosticKey::JoinPendingSupersededByLeave, + code: state.last_join_error, + }); + if state.authoritative.current_channel.is_some() { + actions.push(ChannelJoinAction::SendLeaveCommand { + current_channel: state.authoritative.current_channel, + generation, + epoch, + }); + } + JoinReduceStatus::SupersededByLeave + } else { + if state.authoritative.current_channel.is_some() { + actions.push(ChannelJoinAction::SendLeaveCommand { + current_channel: state.authoritative.current_channel, + generation, + epoch, + }); + } + JoinReduceStatus::Accepted + } + } + ChannelJoinEvent::ReconnectStarted { new_epoch, now: _ } => { + if let Some(pending) = state.pending { + actions.push(ChannelJoinAction::CancelJoinTimeout { + key: pending_key(&pending), + }); + } + state.pending = None; + state.sync_state = ChannelJoinSyncState::Synchronizing { + reason: SyncReason::Reconnect, + epoch: new_epoch, + }; + state.authoritative.confirmed_epoch = new_epoch; + state.bump_generation(); + state.last_join_error = None; + actions.push(ChannelJoinAction::EmitDiagnostic { + key: JoinDiagnosticKey::JoinReconnectSynchronizing, + code: None, + }); + JoinReduceStatus::Accepted + } + ChannelJoinEvent::SnapshotReady { + current_channel, + epoch, + now: _, + } => { + if epoch != state.current_epoch() { + stale(state, &mut actions) + } else { + let pending = state.pending; + if let Some(pending) = pending { + actions.push(ChannelJoinAction::CancelJoinTimeout { + key: pending_key(&pending), + }); + } + state.authoritative.current_channel = current_channel; + state.authoritative.confirmed_epoch = epoch; + state.pending = None; + state.sync_state = ChannelJoinSyncState::Ready; + state.bump_generation(); + state.last_join_error = match pending { + Some(pending) if current_channel != Some(pending.target_channel) => { + Some(JoinErrorCode::JoinReconciledDifferentChannel) + } + _ => None, + }; + actions.push(ChannelJoinAction::EmitDiagnostic { + key: JoinDiagnosticKey::JoinSnapshotReconciled, + code: state.last_join_error, + }); + if pending.is_some() && state.last_join_error.is_none() { + JoinReduceStatus::Confirmed + } else { + JoinReduceStatus::ReconciledBySnapshot + } + } + } + }; + + finish(status, state, actions) +} + +/// Build the channel-join projection for the current reducer state. +pub fn project(state: &ChannelJoinState) -> ChannelJoinProjection { + let pending = state.pending; + let synchronizing = matches!(state.sync_state, ChannelJoinSyncState::Synchronizing { .. }); + ChannelJoinProjection { + current_channel: state.authoritative.current_channel, + pending_target: pending.map(|pending| pending.target_channel), + pending_since: pending.map(|pending| pending.started_at), + pending_generation: pending.map(|pending| pending.generation), + can_join: !synchronizing && pending.is_none(), + can_leave: !synchronizing + && (state.authoritative.current_channel.is_some() || pending.is_some()), + sync_state: state.sync_state, + last_join_error: state.last_join_error, + } +} + +fn finish( + status: JoinReduceStatus, + state: &ChannelJoinState, + mut actions: Vec, +) -> ChannelJoinReduction { + let projection = project(state); + actions.push(ChannelJoinAction::PublishProjection(projection.clone())); + ChannelJoinReduction { + status, + projection, + actions, + } +} + +fn pending_key(pending: &JoinPending) -> JoinOutcomeKey { + JoinOutcomeKey { + connection_epoch: pending.connection_epoch, + generation: pending.generation, + request_id: pending.request_id, + } +} + +fn key_matches(pending: Option, key: JoinOutcomeKey) -> bool { + pending + .map(|pending| pending_key(&pending) == key) + .unwrap_or(false) +} + +fn stale(state: &mut ChannelJoinState, actions: &mut Vec) -> JoinReduceStatus { + state.last_join_error = Some(JoinErrorCode::JoinStaleOutcomeIgnored); + actions.push(ChannelJoinAction::EmitDiagnostic { + key: JoinDiagnosticKey::JoinStaleOutcomeIgnored, + code: state.last_join_error, + }); + JoinReduceStatus::StaleOutcomeIgnored +} + +fn failure_error_code(failure: JoinFailureKind) -> JoinErrorCode { + match failure { + JoinFailureKind::Denied => JoinErrorCode::JoinDenied, + JoinFailureKind::Network => JoinErrorCode::JoinNetworkFailure, + JoinFailureKind::Protocol => JoinErrorCode::JoinProtocolFailure, + JoinFailureKind::Timeout => JoinErrorCode::JoinTimeout, + JoinFailureKind::Unknown => JoinErrorCode::JoinProtocolFailure, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn epoch(value: u64) -> ConnectionEpoch { + ConnectionEpoch(value) + } + + fn channel(value: u64) -> ChannelId { + ChannelId(value) + } + + fn request(value: u64) -> JoinRequestId { + JoinRequestId(value) + } + + fn now(offset: u64) -> Instant { + Instant::now() + std::time::Duration::from_millis(offset) + } + + fn accepted_pending(state: &mut ChannelJoinState, target: ChannelId) -> JoinOutcomeKey { + let start = now(1); + let reduction = reduce( + state, + ChannelJoinEvent::UserJoinRequested { + target_channel: target, + now: start, + }, + ); + let generation = reduction + .projection + .pending_generation + .expect("pending generation"); + let request_id = request(7); + reduce( + state, + ChannelJoinEvent::JoinCommandAccepted { + generation, + request_id, + }, + ); + JoinOutcomeKey { + connection_epoch: epoch(1), + generation, + request_id: Some(request_id), + } + } + + #[test] + fn user_join_creates_pending_and_send_start_timeout_actions() { + let mut state = ChannelJoinState::new(epoch(1)); + let start = now(1); + + let reduction = reduce( + &mut state, + ChannelJoinEvent::UserJoinRequested { + target_channel: channel(10), + now: start, + }, + ); + + assert_eq!(reduction.status, JoinReduceStatus::Accepted); + assert_eq!(reduction.projection.pending_target, Some(channel(10))); + assert!(matches!( + reduction.actions[0], + ChannelJoinAction::SendJoinCommand { + target_channel: ChannelId(10), + generation: JoinGeneration(1), + epoch: ConnectionEpoch(1) + } + )); + assert!( + matches!(reduction.actions[1], ChannelJoinAction::StartJoinTimeout { key: JoinOutcomeKey { connection_epoch: ConnectionEpoch(1), generation: JoinGeneration(1), request_id: None }, started_at } if started_at == start) + ); + } + + #[test] + fn duplicate_same_target_coalesces() { + let mut state = ChannelJoinState::new(epoch(1)); + reduce( + &mut state, + ChannelJoinEvent::UserJoinRequested { + target_channel: channel(10), + now: now(1), + }, + ); + + let reduction = reduce( + &mut state, + ChannelJoinEvent::UserJoinRequested { + target_channel: channel(10), + now: now(2), + }, + ); + + assert_eq!(reduction.status, JoinReduceStatus::CoalescedSameTarget); + assert_eq!( + reduction.projection.pending_generation, + Some(JoinGeneration(1)) + ); + assert!(!reduction.actions.iter().any(|action| matches!( + action, + ChannelJoinAction::SendJoinCommand { .. } | ChannelJoinAction::StartJoinTimeout { .. } + ))); + } + + #[test] + fn different_target_while_pending_rejects_and_serializes() { + let mut state = ChannelJoinState::new(epoch(1)); + reduce( + &mut state, + ChannelJoinEvent::UserJoinRequested { + target_channel: channel(10), + now: now(1), + }, + ); + + let reduction = reduce( + &mut state, + ChannelJoinEvent::UserJoinRequested { + target_channel: channel(11), + now: now(2), + }, + ); + + assert_eq!( + reduction.status, + JoinReduceStatus::Rejected(JoinIntentRejected::JoinAlreadyPendingDifferentTarget) + ); + assert_eq!(reduction.projection.pending_target, Some(channel(10))); + assert_eq!( + reduction.projection.last_join_error, + Some(JoinErrorCode::JoinAlreadyPendingDifferentTarget) + ); + assert!(!reduction + .actions + .iter() + .any(|action| matches!(action, ChannelJoinAction::SendJoinCommand { .. }))); + } + + #[test] + fn authoritative_current_channel_not_changed_until_self_move_or_snapshot() { + let mut state = ChannelJoinState::new(epoch(1)); + state.authoritative.current_channel = Some(channel(1)); + let key = accepted_pending(&mut state, channel(2)); + + let success = reduce(&mut state, ChannelJoinEvent::ProtocolJoinSucceeded { key }); + assert_eq!(success.projection.current_channel, Some(channel(1))); + assert_eq!(success.projection.pending_target, Some(channel(2))); + + let confirmed = reduce( + &mut state, + ChannelJoinEvent::AuthoritativeSelfMove { + channel: Some(channel(2)), + epoch: epoch(1), + source: AuthoritativeSource::LiveDelta, + }, + ); + assert_eq!(confirmed.status, JoinReduceStatus::Confirmed); + assert_eq!(confirmed.projection.current_channel, Some(channel(2))); + assert_eq!(confirmed.projection.pending_target, None); + } + + #[test] + fn failure_and_timeout_clear_pending_and_preserve_previous_authoritative_channel() { + let mut failed = ChannelJoinState::new(epoch(1)); + failed.authoritative.current_channel = Some(channel(1)); + let fail_key = accepted_pending(&mut failed, channel(2)); + + let fail_reduction = reduce( + &mut failed, + ChannelJoinEvent::ProtocolJoinFailed { + key: fail_key, + failure: JoinFailureKind::Denied, + }, + ); + assert_eq!( + fail_reduction.status, + JoinReduceStatus::Failed(JoinFailureKind::Denied) + ); + assert_eq!(fail_reduction.projection.current_channel, Some(channel(1))); + assert_eq!(fail_reduction.projection.pending_target, None); + + let mut timed_out = ChannelJoinState::new(epoch(1)); + timed_out.authoritative.current_channel = Some(channel(1)); + let timeout_key = accepted_pending(&mut timed_out, channel(2)); + let timeout_reduction = reduce( + &mut timed_out, + ChannelJoinEvent::JoinTimeout { + key: timeout_key, + now: now(9), + }, + ); + assert_eq!(timeout_reduction.status, JoinReduceStatus::TimedOut); + assert_eq!( + timeout_reduction.projection.current_channel, + Some(channel(1)) + ); + assert_eq!(timeout_reduction.projection.pending_target, None); + } + + #[test] + fn stale_outcomes_ignored_by_epoch_generation_or_request_mismatch() { + let mut state = ChannelJoinState::new(epoch(1)); + state.authoritative.current_channel = Some(channel(1)); + let key = accepted_pending(&mut state, channel(2)); + + for stale_key in [ + JoinOutcomeKey { + connection_epoch: epoch(2), + ..key + }, + JoinOutcomeKey { + generation: JoinGeneration(99), + ..key + }, + JoinOutcomeKey { + request_id: Some(request(99)), + ..key + }, + ] { + let reduction = reduce( + &mut state, + ChannelJoinEvent::ProtocolJoinFailed { + key: stale_key, + failure: JoinFailureKind::Network, + }, + ); + assert_eq!(reduction.status, JoinReduceStatus::StaleOutcomeIgnored); + assert_eq!(reduction.projection.current_channel, Some(channel(1))); + assert_eq!(reduction.projection.pending_target, Some(channel(2))); + } + } + + #[test] + fn leave_supersedes_pending_and_emits_leave_action_if_current_exists() { + let mut state = ChannelJoinState::new(epoch(1)); + state.authoritative.current_channel = Some(channel(1)); + let key = accepted_pending(&mut state, channel(2)); + + let reduction = reduce( + &mut state, + ChannelJoinEvent::UserLeaveRequested { now: now(3) }, + ); + assert_eq!(reduction.status, JoinReduceStatus::SupersededByLeave); + assert_eq!(reduction.projection.current_channel, Some(channel(1))); + assert_eq!(reduction.projection.pending_target, None); + assert!(reduction.actions.iter().any(|action| matches!( + action, + ChannelJoinAction::SendLeaveCommand { + current_channel: Some(ChannelId(1)), + generation: JoinGeneration(2), + epoch: ConnectionEpoch(1) + } + ))); + + let stale_success = reduce(&mut state, ChannelJoinEvent::ProtocolJoinSucceeded { key }); + assert_eq!(stale_success.status, JoinReduceStatus::StaleOutcomeIgnored); + assert_eq!(stale_success.projection.current_channel, Some(channel(1))); + } + + #[test] + fn reconnect_clears_stales_pending_and_updates_sync_epoch() { + let mut state = ChannelJoinState::new(epoch(1)); + state.authoritative.current_channel = Some(channel(1)); + let key = accepted_pending(&mut state, channel(2)); + + let reconnect = reduce( + &mut state, + ChannelJoinEvent::ReconnectStarted { + new_epoch: epoch(2), + now: now(4), + }, + ); + assert_eq!(reconnect.projection.pending_target, None); + assert_eq!( + reconnect.projection.sync_state, + ChannelJoinSyncState::Synchronizing { + reason: SyncReason::Reconnect, + epoch: epoch(2) + } + ); + assert!(!reconnect.projection.can_join); + + let stale_failure = reduce( + &mut state, + ChannelJoinEvent::ProtocolJoinFailed { + key, + failure: JoinFailureKind::Network, + }, + ); + assert_eq!(stale_failure.status, JoinReduceStatus::StaleOutcomeIgnored); + } + + #[test] + fn snapshot_replaces_authoritative_membership_and_clears_or_reconciles_pending() { + let mut confirmed = ChannelJoinState::new(epoch(1)); + accepted_pending(&mut confirmed, channel(2)); + let confirm = reduce( + &mut confirmed, + ChannelJoinEvent::SnapshotReady { + current_channel: Some(channel(2)), + epoch: epoch(1), + now: now(5), + }, + ); + assert_eq!(confirm.status, JoinReduceStatus::Confirmed); + assert_eq!(confirm.projection.current_channel, Some(channel(2))); + assert_eq!(confirm.projection.pending_target, None); + + let mut reconciled = ChannelJoinState::new(epoch(1)); + accepted_pending(&mut reconciled, channel(2)); + let reconcile = reduce( + &mut reconciled, + ChannelJoinEvent::SnapshotReady { + current_channel: Some(channel(3)), + epoch: epoch(1), + now: now(6), + }, + ); + assert_eq!(reconcile.status, JoinReduceStatus::ReconciledBySnapshot); + assert_eq!(reconcile.projection.current_channel, Some(channel(3))); + assert_eq!(reconcile.projection.pending_target, None); + assert_eq!( + reconcile.projection.last_join_error, + Some(JoinErrorCode::JoinReconciledDifferentChannel) + ); + } +} diff --git a/crates/chanora_state/src/lib.rs b/crates/chanora_state/src/lib.rs index 9a80270..6b5c8c2 100644 --- a/crates/chanora_state/src/lib.rs +++ b/crates/chanora_state/src/lib.rs @@ -13,6 +13,8 @@ #![forbid(unsafe_code)] #![warn(missing_docs)] +pub mod channel_join; + use thiserror::Error; /// Errors raised while reducing protocol events into state or diff --git a/docs/architecture/sad.md b/docs/architecture/sad.md index dd68401..1e47f87 100644 --- a/docs/architecture/sad.md +++ b/docs/architecture/sad.md @@ -3,7 +3,7 @@ **Document type:** SAD / Software Architecture Description **Process alignment:** ASPICE SWE.2 Software Architectural Design -**Version:** 0.9.8 +**Version:** 0.9.11 **Status:** Baseline Candidate **Language:** English **Product:** Chanora @@ -1478,15 +1478,77 @@ A single SRS item may be intentionally allocated to more than one SAD item when Pinned at SAD layer to remove a downstream ambiguity that would otherwise surface as an open question in the SAD-090 SDD unit: the 🟡 "within tolerance but trending" marker (SRS-218 clause 3, third element of the marker set) shall be defined as follows: a metric is rendered 🟡 if and only if (a) the metric is within the SRS-219 per-metric tolerance window relative to the merge-base baseline (i.e. it is not 🔴), AND (b) the absolute delta between the PR's current measurement and the **previous run of the SAD-090 workflow on the default branch** (i.e. the most recent default-branch CI bench result, not the baseline JSON itself) is greater than 50% of the SRS-219 tolerance window for that metric. Metrics whose SRS-219 tolerance is exactly zero (the heap-allocation-count metric, SRS-219 clause a) cannot be 🟡 because the 50% trigger is undefined when the window has zero width; for those metrics only 🟢 (zero allocations) and 🔴 (any non-zero allocation count) apply. Rationale for pinning at SAD rather than deferring to SDD: the question "is yellow relative to baseline or relative to previous-run-on-default" is an architectural choice about what state the workflow consumes (baseline JSON only, vs baseline JSON + a previous-runs cache), not an implementation detail; pinning it here fixes the workflow's required inputs at the architecture layer so SDD has a fully determined input set. Operationally, the "previous-run-on-default" datum may be sourced from the SAD-091 baseline (treating the committed baseline as the latest default-branch run, which is the simpler and recommended SDD realization) or from a separate run-history artifact; SDD selects which. -## 27. Updated SRS-to-SAD Coverage Matrix +## 27. Channel Join Pending-State Architecture + +**SAD-092**: The software architecture shall assign authoritative current-channel membership ownership to Rust Core and the State Synchronization Engine. The authoritative current channel is the last channel membership state confirmed by Rust Core from the protocol path or from a reconciled server snapshot. Flutter state, Flutter feature screens, and bridge DTO consumers shall treat that value as read-only render state and shall not create, overwrite, or finalize authoritative membership locally. + +- Status: Baseline Candidate +- Type: Software Architecture Item +- Stage: P0 / MVP +- Allocated to: Rust Core, State Sync +- Source SRS: SRS-023, SRS-054, SRS-056, SRS-057, SRS-058 +- Related SysDes: SysDes-069 +- Verification method: Architecture Review, Integration Test, System Test + +**SAD-093**: The software architecture shall model a channel-join request as user intent crossing the bridge, not as an immediate local membership mutation. Flutter may expose the requested target channel as a non-authoritative pending target for visual feedback while continuing to render the authoritative current channel from SAD-092 until Rust Core/protocol confirmation changes that authoritative state. The bridge shall provide command and event contracts that distinguish join intent, pending target visibility, confirmed membership, and user-safe failure reporting; the bridge and Flutter layers shall not directly mutate authoritative channel membership. + +- Status: Baseline Candidate +- Type: Software Architecture Item +- Stage: P0 / MVP +- Allocated to: Flutter UI, Flutter State, Bridge Facade, Rust Core +- Source SRS: SRS-023, SRS-049, SRS-054 +- Related SysDes: SysDes-069 +- Verification method: UI Test, Integration Test, System Test + +**SAD-094**: The software architecture shall preserve the previous authoritative current channel on channel-join failure or timeout and shall clear the non-authoritative pending target through Rust Core / State Sync events. Join denial, network/protocol error, timeout, and cancellation-like outcomes shall be surfaced as user-safe errors without reclassifying the pending target as the current channel. Diagnostics shall record the join lifecycle at sanitized event level, including request, confirmation, failure, timeout, stale outcome, and reconciliation decisions, without leaking secrets or raw protocol payloads. + +- Status: Baseline Candidate +- Type: Software Architecture Item +- Stage: P0 / MVP +- Allocated to: Rust Core, State Sync, Protocol Adapter, Bridge Facade, Diagnostics, Flutter State +- Source SRS: SRS-023, SRS-048, SRS-049, SRS-054, SRS-056, SRS-057, SRS-058 +- Related SysDes: SysDes-069 +- Verification method: Integration Test, System Test, Diagnostics Review + +**SAD-095**: The downstream SDD shall define deterministic State Sync handling for channel-join edge cases while preserving the SAD-092 authoritative ownership boundary. Required SDD coverage includes duplicate join requests, a different target requested while a join is pending, leave/switch races, stale acknowledgements or failures for superseded requests, reconnect and fresh-snapshot reconciliation, and per-connection ordering boundaries. The architecture requires a single deterministic reducer/state-machine ownership point for those cases in Rust Core / State Sync; Flutter may render only the resulting authoritative and pending-view states. + +- Status: Baseline Candidate +- Type: Software Architecture Item +- Stage: P0 / MVP +- Allocated to: State Sync, Rust Core, Protocol Adapter, Bridge Facade, Flutter State, Verification Support +- Source SRS: SRS-023, SRS-049, SRS-054, SRS-056, SRS-057, SRS-058 +- Related SysDes: SysDes-069 +- Verification method: Architecture Review, SDD Inspection, Unit Test, Integration Test, System Test + +**SAD-096**: The software architecture shall expose an explicit Protocol Adapter command seam for leaving the current voice channel / moving the current client out of channel membership; downstream design may name the seam `leave_channel`, `move_out_of_channel`, or an equivalent verb, but it shall not require Rust Core, Bridge, Flutter, or the State Sync reducer to invent a synthetic channel id or rely on implicit `move_to_channel` behavior to realize `voice_leave()`. Rust Core shall invoke this Protocol Adapter seam only as a side effect of the State Sync reducer returning a leave action, and the Protocol Adapter shall report command lifecycle separately from authoritative membership. Authoritative current-channel membership remains owned by Rust Core / State Sync under SAD-092: a successful leave/move-out command acknowledgement is command acceptance/completion evidence only, while the membership value changes to `None` or to a server-provided replacement channel only after a live protocol self-move/client-leave delta or a reconciled snapshot confirms that state. Protocol errors, denials, timeouts, and stale outcomes shall be returned to Rust Core as user-safe error categories and sanitized diagnostics; raw protocol payloads, passwords, server addresses, and unredacted external strings shall not cross into logs or Flutter DTOs. + +- Status: Baseline Candidate +- Type: Software Architecture Item +- Stage: P0 / MVP +- Allocated to: Rust Core, State Sync, Protocol Adapter, Bridge Facade, Diagnostics, Verification Support +- Source SRS: SRS-021, SRS-023, SRS-038, SRS-054, SRS-056, SRS-057, SRS-058, SRS-204 +- Related SysDes: SysDes-069 +- Verification method: Architecture Review, Integration Test, System Test, Diagnostics Review + +### SWE.5 verification implications + +SWE.5 verification shall include evidence that channel-join pending-state behavior remains server-authoritative end to end: Flutter sends intent only, authoritative current-channel state changes only after Rust Core/protocol confirmation or snapshot reconciliation, failure/timeout keeps the prior current channel, and edge-case sequences named in SAD-095 are deterministic. Tests shall cover both UI-visible pending feedback and Rust Core / State Sync reducer behavior through bridge-level integration seams. SWE.5 shall also verify the SAD-096 leave/move-out seam end to end: `voice_leave()` / `leave_channel()` intent reduces to a single Protocol Adapter leave/move-out command side effect, no synthetic target channel id is generated by upper layers, command success alone does not clear authoritative membership, and live-delta/snapshot confirmation is required before the projection reports `current_channel = None` or a server-provided replacement. + +## 28. Updated SRS-to-SAD Coverage Matrix | SRS Range | SAD Coverage | |---|---| | SRS-001 through SRS-184 | Covered by inherited SAD baseline `SAD-001` through `SAD-060` | +| SRS-021 (client join/leave/move events reflected in channel display) | Covered specifically for current-channel leave/move-out confirmation by `SAD-096` | +| SRS-023 (channel join confirmation before authoritative UI state) | Covered specifically by `SAD-092` through `SAD-096` | +| SRS-038 (Rust Core bridge command suite) | Covered specifically for voice/channel leave command routing by `SAD-096` | +| SRS-049 (Protocol Adapter channel join success/failure reporting) | Covered specifically by `SAD-093` through `SAD-095` | +| SRS-054 (State Sync connection state model) | Covered specifically for current-channel membership by `SAD-092` through `SAD-096` | +| SRS-056, SRS-057, SRS-058 (deterministic deltas, ordering, reducers) | Covered specifically for pending-join and leave/move-out edge cases by `SAD-092`, `SAD-094`, `SAD-095`, and `SAD-096` | | SRS-185 through SRS-194 | Covered by `SAD-061` through `SAD-070` | | SRS-195 through SRS-199, SRS-201 through SRS-203 | Covered by `SAD-071` through `SAD-079` | | SRS-200 | Covered by `SAD-080` | -| SRS-204 through SRS-207 | Covered by `SAD-081` through `SAD-083` | +| SRS-204 through SRS-207 | Covered by `SAD-081` through `SAD-083`; the protocol leave/move-out realization required by `voice_leave()` is covered by `SAD-096` | | SRS-208 | Covered by `SAD-084` | | SRS-209 | Covered by `SAD-085` | | SRS-111 (Android foreground voice service) | Covered by `SAD-086` (Android-specific allocation; SAD-036 retains the cross-cutting diagnostics allocation for the SRS-111–124 range per §24.1 dual-allocation pattern) | @@ -1539,6 +1601,20 @@ Pinned at SAD layer to remove a downstream ambiguity that would otherwise surfac | 0.9.6 | 2026-05-17 | Propagated reconciled P0 Android SRS updates into SAD. SAD-063 text extended in place (ID preserved) to record the API 28 minimum runtime baseline per DEC-004 (Accepted 2026-05-14, superseding the earlier API 24 recommendation), while keeping the existing SRS-187 / SRS-188 trace. Added SAD-084 (Android in-call audio mode controller in the platform audio adapter, bound to the voice-session lifecycle, sourced from SRS-208). Added SAD-085 (Android `RECORD_AUDIO` runtime permission flow on the existing Android permission adapter, naming listen-only — `capture_active = false` with output stream open per SAD-081 — as a first-class operating mode at the architecture layer, sourced from SRS-209). Added SAD-086 (`AndroidVoiceForegroundService` allocation for SRS-111, bound to `voice_join` / `voice_leave` lifecycle rather than UI lifecycle, coordinating with SAD-084 and SAD-085). SAD-018 (Android back intent, SRS-163) and the existing Android AAB allocation in §9 / SAD-037 (SRS-119) were re-read and left unchanged because they remain coherent with the reconciled SRS. Strict layered sourcing preserved (`SAD -> SRS` only). | +## Baseline Candidate 0.9.10 Update + +| Version | Date | Description | +|---|---|---| +| 0.9.10 | 2026-05-18 | Added dedicated channel-join pending-state architecture items SAD-092 through SAD-095. The new items assign authoritative current-channel membership to Rust Core / State Sync, constrain Flutter to user intent plus non-authoritative pending-target rendering, preserve the prior authoritative channel on failure or timeout, require sanitized diagnostics for join lifecycle outcomes, and push deterministic duplicate/different-target/leave-race/stale-ack/reconnect-reconciliation handling into SDD under the Rust Core / State Sync ownership boundary. Added specific §28 coverage rows for SRS-023, SRS-049, SRS-054, and SRS-056 through SRS-058, with SysDes-069 recorded as a related upstream design anchor while retaining SRS as the direct SAD source. | + + +## Baseline Candidate 0.9.11 Update + +| Version | Date | Description | +|---|---|---| +| 0.9.11 | 2026-05-19 | Added SAD-096 to close the protocol leave/move-out architecture seam blocking SDD-121 implementation. The Protocol Adapter must expose an explicit leave/move-out command seam for `voice_leave()` / `leave_channel()` side effects; upper layers must not synthesize channel ids or rely on implicit `move_to_channel` behavior. Command acknowledgement remains separate from authoritative membership: Rust Core / State Sync clear or replace `current_channel` only from authoritative live deltas or reconciled snapshots. SWE.5 implications and §28 SRS coverage rows were updated for SRS-021, SRS-023, SRS-038, SRS-054, SRS-056 through SRS-058, and SRS-204. Strict layered sourcing preserved (`SAD -> SRS` only). | + + ## Baseline Candidate 0.9.7 Update | Version | Date | Description | diff --git a/docs/architecture/sdd.md b/docs/architecture/sdd.md index 5e54083..ac7eab8 100644 --- a/docs/architecture/sdd.md +++ b/docs/architecture/sdd.md @@ -3,7 +3,7 @@ **Document type:** SDD / Software Detailed Design **Process alignment:** ASPICE SWE.3 Software Detailed Design and Unit Construction -**Version:** 0.9.16 +**Version:** 0.9.21 **Status:** Baseline Candidate **Language:** English **Product:** Chanora @@ -1810,6 +1810,100 @@ Notes: - This is a forward-looking SDD unit. The bench files, the Cargo.toml additions, the two workflow YAML files, the `emit_baseline.rs` / `compare_baseline.rs` binaries, and the initial seed of `crates/chanora_audio/benches/baselines/x86_64-unknown-linux-gnu.json` are NEW implementation work for a builder agent. The seed baseline file is generated by manually dispatching `bench-baseline-update.yml` once after the builder lands the rest of the unit; that first dispatch is the bootstrap that the §6 step 9 short-circuit branch will then no longer take on subsequent PR runs. - The `tsclientlib::AudioHandler::fill_buffer` decode-side bench was explicitly NOT selected as the canonical `opus_decode_latency` target (see §3 item 4 rationale). A future SDD revision may add `audio_handler_fill_buffer_latency` as a separate composite-call metric without modifying the canonical Opus codec metric. +### Channel Join Pending-State Detailed Design (SDD-121) + +**SDD-121**: `chanora_state::channel_join` shall own the single deterministic reducer for user-initiated channel-join pending state, authoritative current-channel membership, and reconnect/snapshot reconciliation. Flutter and `chanora_bridge` shall send join/leave intent and render the reducer output only; neither layer shall create, overwrite, or finalize authoritative current-channel membership. + +- Status: Draft +- Type: Software Detailed Design Item +- Stage: P0 / MVP +- Software unit: `chanora_state::channel_join` reducer, `chanora_protocol::adapter` join/move command reporting, `chanora_bridge` command/event DTOs, Flutter channel tree / voice view models +- Source SAD: SAD-092, SAD-093, SAD-094, SAD-095, SAD-096 +- Upstream software requirements are traced only through SAD coverage; SDD-121 shall not directly source or enumerate SRS identifiers. +- Cross-trace SDD: SDD-020 (`ChanoraChannelTree` join intent callbacks), SDD-094 (`voice_join` / `voice_leave` lifecycle bridge naming), SDD-097 (`ChanoraVoiceBar` current-channel rendering) +- Verification method: Unit Test (reducer transition table), Integration Test (bridge + protocol adapter seams), Widget Test (pending visual state), Fault-Injection Test (timeout/reconnect/stale outcome) + +Implementation requirements: + +1. Concrete Rust module/API surface owned by `chanora_state::channel_join`: + - The crate root shall expose `pub mod channel_join;` and shall not hide join-pending state in `chanora_core` or Flutter-local models. + - Type aliases/newtypes shall be declared in the module, or imported from an existing shared state-id module if one exists at implementation time: `ChannelId`, `ConnectionEpoch`, `JoinGeneration`, `JoinRequestId`, and `LeaveRequestId`. If existing repository types already define channel/request identifiers, the module shall use those concrete types rather than invent parallel identifiers; otherwise minimal `#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]` newtypes are authorized for the reducer seam. + - `AuthoritativeMembership` shall contain `current_channel: Option` and `confirmed_epoch: ConnectionEpoch`. `None` represents no confirmed voice-channel membership. + - `JoinPending` shall contain `target_channel: ChannelId`, `previous_confirmed_channel: Option`, `request_id: Option`, `generation: JoinGeneration`, `started_at: Instant`, and `connection_epoch: ConnectionEpoch`. + - `ChannelJoinState` shall contain `authoritative: AuthoritativeMembership`, `pending: Option`, `sync_state: ChannelJoinSyncState`, and `next_generation: JoinGeneration`. + - `ChannelJoinSyncState` shall be `Ready` or `Synchronizing { reason: SyncReason, epoch: ConnectionEpoch }`; `SyncReason` shall include `InitialSnapshot` and `Reconnect`. + - `JoinOutcomeKey` shall contain `connection_epoch: ConnectionEpoch`, `generation: JoinGeneration`, and `request_id: Option`. `request_id: None` is valid only before command-send acceptance; protocol success/failure/timeout outcomes shall carry `Some(request_id)`. `JoinRequestId` shall be unique within a connection epoch. The reducer shall treat `(connection_epoch, generation, request_id)` as the outcome-correlation key. Generation shall increment for every user join request, pending-canceling leave request, reconnect epoch change, and fresh snapshot reconciliation. + - `LeaveOutcomeKey` shall contain `connection_epoch: ConnectionEpoch`, `generation: JoinGeneration`, and `request_id: LeaveRequestId`. `LeaveRequestId` shall be unique within a connection epoch. Leave command success/failure/timeout events shall carry this key; it correlates command lifecycle only and shall not be treated as authoritative membership evidence. + - `ChannelJoinProjection` shall be the only reducer view-model shape consumed by core/bridge mapping. It shall contain `current_channel: Option`, `pending_target: Option`, `pending_since: Option`, `pending_generation: Option`, `can_join: bool`, `can_leave: bool`, `sync_state: ChannelJoinSyncState`, and `last_join_error: Option`. Bridge DTOs may convert `pending_since` to elapsed milliseconds or a timestamp but shall preserve the same semantics. +2. Concrete reducer event, action, outcome, and function signatures: + - `ChannelJoinEvent` variants shall be: `UserJoinRequested { target_channel: ChannelId, now: Instant }`; `JoinCommandAccepted { generation: JoinGeneration, request_id: JoinRequestId }`; `JoinCommandRejectedBeforeSend { generation: JoinGeneration, error: JoinFailureKind }`; `ProtocolJoinSucceeded { key: JoinOutcomeKey }`; `ProtocolJoinFailed { key: JoinOutcomeKey, failure: JoinFailureKind }`; `JoinTimeout { key: JoinOutcomeKey, now: Instant }`; `AuthoritativeSelfMove { channel: Option, epoch: ConnectionEpoch, source: AuthoritativeSource }`; `UserLeaveRequested { now: Instant }`; `LeaveCommandAccepted { generation: JoinGeneration, request_id: LeaveRequestId }`; `ProtocolLeaveSucceeded { key: LeaveOutcomeKey }`; `ProtocolLeaveFailed { key: LeaveOutcomeKey, failure: JoinFailureKind }`; `LeaveTimeout { key: LeaveOutcomeKey, now: Instant }`; `ReconnectStarted { new_epoch: ConnectionEpoch, now: Instant }`; and `SnapshotReady { current_channel: Option, epoch: ConnectionEpoch, now: Instant }`. + - `AuthoritativeSource` shall include `LiveDelta` and `Snapshot`. + - `ChannelJoinAction` variants shall be: `SendJoinCommand { target_channel: ChannelId, generation: JoinGeneration, epoch: ConnectionEpoch }`; `StartJoinTimeout { key: JoinOutcomeKey, started_at: Instant }`; `CancelJoinTimeout { key: JoinOutcomeKey }`; `SendLeaveCommand { generation: JoinGeneration, epoch: ConnectionEpoch }`; `StartLeaveTimeout { key: LeaveOutcomeKey, started_at: Instant }`; `CancelLeaveTimeout { key: LeaveOutcomeKey }`; `PublishProjection(ChannelJoinProjection)`; and `EmitDiagnostic { key: JoinDiagnosticKey, code: Option }`. `SendLeaveCommand` deliberately carries no target `ChannelId`; any implementation that requires a dummy or synthetic channel id is non-conformant. + - `JoinReduceStatus` shall be `Accepted`, `CoalescedSameTarget`, `Rejected(JoinIntentRejected)`, `StaleOutcomeIgnored`, `Confirmed`, `Failed(JoinFailureKind)`, `TimedOut`, `SupersededByLeave`, `LeaveCommandAccepted`, `LeaveCommandCompleted`, `LeaveCommandFailed(JoinFailureKind)`, `LeaveTimedOut`, or `ReconciledBySnapshot`. + - `JoinIntentRejected` shall include `JoinAlreadyPendingDifferentTarget` and `CannotJoinWhileSynchronizing`. + - `JoinFailureKind` shall include `Denied`, `Network`, `Protocol`, `Timeout`, and `Unknown`. + - `JoinErrorCode` shall include stable names required by bridge/UI mapping: `DuplicateSameTargetCoalesced`, `JoinAlreadyPendingDifferentTarget`, `JoinDenied`, `JoinProtocolFailure`, `JoinNetworkFailure`, `JoinTimeout`, `JoinSupersededByLeave`, `JoinStaleOutcomeIgnored`, `JoinReconciledDifferentChannel`, `JoinCommandRejectedBeforeSend`, `JoinCannotStartWhileSynchronizing`, `LeaveProtocolFailure`, `LeaveNetworkFailure`, `LeaveTimeout`, `LeaveStaleOutcomeIgnored`, and `LeaveCommandRejectedBeforeSend`. + - Reducer signatures shall be implementation-compatible with: `ChannelJoinState::new(epoch: ConnectionEpoch) -> ChannelJoinState`; `fn reduce(state: &mut ChannelJoinState, event: ChannelJoinEvent) -> ChannelJoinReduction`; `fn project(state: &ChannelJoinState) -> ChannelJoinProjection`; and `struct ChannelJoinReduction { status: JoinReduceStatus, projection: ChannelJoinProjection, actions: Vec }`. The reducer shall be deterministic for a given state/event; all wall-clock values enter through event `now` or `started_at` fields, not through hidden calls inside the reducer. + - `StaleJoinOutcome` shall not be accepted from outside the reducer; it is the reducer's diagnostic classification when an incoming success/failure/timeout key does not match the active pending key. +3. User join request behavior: + - If no join is pending, `UserJoinRequested` shall create `JoinPending` with the target channel, the current `authoritative.current_channel` as `previous_confirmed_channel`, a new generation, `started_at` from the event `now`, and no request id until the protocol command is accepted for send. The emitted actions shall include `PublishProjection(project(state))` immediately followed by `SendJoinCommand { target_channel, generation, epoch }`; the owning runtime shall publish the pending projection before starting protocol I/O so Flutter can render the non-authoritative pending target during the send attempt. + - `JoinCommandAccepted` shall attach the protocol request id to the active pending entry only when its generation matches the active pending generation, then emit `StartJoinTimeout { key: JoinOutcomeKey { connection_epoch, generation, request_id: Some(request_id) }, started_at }` and `PublishProjection(project(state))`. If the command-send path fails before a request id exists, `JoinCommandRejectedBeforeSend` shall clear pending, retain `previous_confirmed_channel` as authoritative, and publish the resulting projection. + - Duplicate same-target `UserJoinRequested` while an active pending entry has the same `target_channel` shall coalesce: no new protocol command, generation, timeout, or diagnostic error shall be produced. The reducer shall return the current pending view state and may emit a sanitized `join_duplicate_coalesced` diagnostic. + - Different-target `UserJoinRequested` while pending shall be rejected/serialized for P0: keep the existing pending entry, emit no new protocol command, and return `JoinIntentRejected::JoinAlreadyPendingDifferentTarget`. Flutter shall keep the original target marked pending and shall surface a user-safe "finish or cancel the current join first" message. Automatic switch/cancel-on-new-target is deliberately not selected in this revision. +4. Confirmation behavior: + - Authoritative membership changes only on `AuthoritativeSelfMove` or `SnapshotReady`. `ProtocolJoinSucceeded` is not by itself final membership; it clears neither pending nor `previous_confirmed_channel` unless paired with a matching authoritative self-move/snapshot confirmation. This prevents optimistic finalization when the protocol command returns before the server delta is reduced. + - If `AuthoritativeSelfMove { channel: Some(target) }` or `SnapshotReady { current_channel: Some(target) }` matches the active pending `target_channel` in the same connection epoch, the reducer shall set `authoritative.current_channel = Some(target)`, clear `pending`, and emit `JoinViewState::Confirmed { current_channel: target }` plus a sanitized `join_confirmed` diagnostic. + - If the server authoritatively moves the user to a different channel while a join is pending, the reducer shall accept the authoritative channel as current, clear pending as superseded, and emit `JoinViewState::FailedOrSuperseded { prior_or_server_channel }` with diagnostic key `join_reconciled_different_channel`. The pending target shall never become current unless it is the authoritative value. +5. Failure and timeout behavior: + - `ProtocolJoinFailed` with a matching active key shall clear `pending`, leave `authoritative.current_channel` equal to `previous_confirmed_channel`, and return `JoinFailure::Denied | Network | Protocol | Unknown` mapped to a user-safe localized message. Raw protocol payloads, passwords, server addresses, and permission tokens shall not enter diagnostics. + - `JoinTimeout` with a matching active key shall clear `pending`, preserve `previous_confirmed_channel` as authoritative, and emit `JoinFailure::Timeout`. The timeout duration is configuration-owned by Rust Core; the SDD requires a deterministic timer seam but does not mandate a product-visible duration. + - Success, failure, or timeout with a non-matching `(connection_epoch, generation, request_id)` shall be ignored for state mutation and shall emit only sanitized diagnostic key `join_stale_outcome_ignored` with the stale/current generations and no raw protocol payload. +6. Leave/switch race behavior: + - `UserLeaveRequested` while no join is pending shall emit `SendLeaveCommand { generation, epoch }` when `authoritative.current_channel` is `Some(_)`. The action is a current-client leave/move-out request with no target channel and no channel password. If `authoritative.current_channel` is already `None`, the reducer shall publish the unchanged projection and shall not emit a protocol command. + - `UserLeaveRequested` while a join is pending shall supersede the pending join locally: increment generation, clear pending view state, emit a best-effort cancellation diagnostic `join_pending_superseded_by_leave`, and dispatch `SendLeaveCommand { generation, epoch }` only if the current authoritative channel is `Some(_)`. The reducer shall ignore later matching command success/failure for the superseded join as stale. If a live authoritative self-move to the former pending target arrives before the leave is confirmed, the reducer shall accept that server-authoritative current channel and the in-flight leave shall then be responsible for moving to `None` or the server-provided post-leave state. + - `LeaveCommandAccepted` shall attach a `LeaveOutcomeKey` for timeout and stale-outcome correlation and may emit `StartLeaveTimeout`; it shall not mutate `authoritative.current_channel` or clear `current_channel` in the projection. `ProtocolLeaveSucceeded` with a matching key records command completion only and may cancel the leave timeout and emit a sanitized diagnostic; it shall not change authoritative membership. `ProtocolLeaveFailed` with a matching key and `LeaveTimeout` with a matching key shall leave `authoritative.current_channel` unchanged, set `last_join_error` to the corresponding leave-safe code, and publish the unchanged authoritative projection. Stale leave success/failure/timeout keys shall be ignored for state mutation and may emit `leave_stale_outcome_ignored`. + - Authoritative leave completion is represented only by `AuthoritativeSelfMove { channel: None, source: LiveDelta }`, `AuthoritativeSelfMove { channel: Some(server_channel), source: LiveDelta }`, or `SnapshotReady { current_channel: None | Some(server_channel), ... }`. These events update the projection to `current_channel = None` or to the server-provided replacement channel. Command acknowledgement is never an authoritative membership confirmation. + - Different-target switch while pending is not a leave; it follows item 3 rejection semantics. A future SDD may define explicit "cancel then join new target" behavior, but P0 serializes it. +7. Reconnect and snapshot reconciliation: + - `ReconnectStarted` shall move `sync_state` to `Synchronizing`, increment the connection epoch/generation, and mark any active pending entry as non-authoritative and stale. The UI may continue to display the last confirmed `authoritative.current_channel` as "last known" but shall disable channel actions requiring a ready connection. + - `SnapshotReady` shall replace `authoritative.current_channel` with the snapshot's self-channel value for that epoch. If the snapshot channel equals the former pending target, the result is confirmed by snapshot; otherwise the pending target is cleared and the snapshot value wins. If the snapshot contains no current channel, authoritative current becomes `None`. + - Live deltas from an old epoch shall be ignored for membership mutation after reconnect epoch advancement; live deltas in the new epoch shall be reduced in arrival order after the snapshot-ready boundary. +8. Bridge/core/protocol integration seams: + - On bridge `voice_join(channel_id)` / `join_channel(target_channel)` intent, `chanora_core` shall enqueue `ChannelJoinEvent::UserJoinRequested { target_channel, now }` into the per-connection State Sync reducer task before sending any protocol command. If the reduction returns `SendJoinCommand`, core shall call the existing protocol join/move command seam and then feed either `JoinCommandAccepted { generation, request_id }` or `JoinCommandRejectedBeforeSend { generation, error }` back through the same reducer queue. Duplicate same-target reductions return command-accepted/coalesced semantics to the bridge without sending a second protocol command. Different-target pending reductions map to `JoinIntentRejected::JoinAlreadyPendingDifferentTarget`. + - Phase B request-id allocation rule: the protocol adapter shall pass through a transport request id when the underlying protocol exposes one. If the adapter accepts/sends a join command but exposes no transport request id, `chanora_core` shall allocate a core-local `JoinRequestId` in the protocol side-effect handler after the reducer action has been captured and the session-state lock has been released, immediately before enqueuing `JoinCommandAccepted`. A monotonically incremented unsigned counter scoped to the current `ConnectionEpoch` is an approved substitute for P0; the counter shall reset or be namespaced on epoch change and shall never be reused within one epoch. The allocated id shall be written only through `JoinCommandAccepted { generation, request_id }`; all subsequent protocol success/failure callbacks and timeout tasks created for that accepted command shall carry `JoinOutcomeKey { connection_epoch: epoch, generation, request_id: Some(request_id) }`. Outcomes without a matching accepted id, generation, and epoch shall reduce to stale/no-op behavior. + - On protocol command result, the Protocol Adapter shall report command lifecycle separately from authoritative membership: command accepted/sent, command success, command failure. `ProtocolJoinSucceeded { key }` confirms only that the command completed; it shall not update `authoritative.current_channel`. `ProtocolJoinFailed { key, failure }` and `JoinTimeout { key, now }` may clear pending only when the key matches the active pending entry. + - On authoritative self move or live membership delta, `chanora_core` / State Sync shall enqueue `AuthoritativeSelfMove { channel, epoch, source: LiveDelta }`. On initial or reconnect snapshot completion, it shall enqueue `SnapshotReady { current_channel, epoch, now }`. These are the only events that may finalize or replace `authoritative.current_channel`. + - On reconnect start, `chanora_core` shall enqueue `ReconnectStarted { new_epoch, now }` before accepting live deltas for the new connection. Protocol outcomes and live deltas carrying older epochs shall be reduced as stale/no-op for membership mutation. + - Protocol Adapter shall expose an explicit leave/move-out API seam. Preferred public Rust signature, compatible with the existing `ProtocolClient` async method style, is `pub async fn leave_channel(&self) -> Result<(), ProtocolError>`; `move_out_of_channel(&self)` is an acceptable equivalent if used consistently. The corresponding internal request shape shall have no `channel_id` field, for example `Request::LeaveChannel { reply: oneshot::Sender> }`. The seam may internally call the protocol library's native leave/current-client-move-out primitive; if the protocol library represents leave with an optional target, that mapping is owned only by `chanora_protocol::adapter` and shall not leak to Rust Core, Bridge, Flutter, or the reducer. + - On bridge `voice_leave()` / `leave_channel()` intent, `chanora_core` shall enqueue `UserLeaveRequested { now }`. If the returned action includes `SendLeaveCommand`, core shall execute that action through the explicit Protocol Adapter leave/move-out seam (`leave_channel` / `move_out_of_channel` or equivalent); suppressing it, treating it as projection-only, or translating it into `move_to_channel()` is not permitted. Leave supersedes active pending join locally but shall not clear or finalize authoritative current membership until an authoritative self move, snapshot, or projection produced from such authoritative confirmation establishes `None` or a different server-provided value. + - Leave command result mapping shall mirror the join command lifecycle separation. If the adapter accepts/sends the command and exposes no transport request id, `chanora_core` shall allocate a core-local `LeaveRequestId` scoped to the `ConnectionEpoch` after releasing the session/state lock and before enqueuing `LeaveCommandAccepted`. Adapter success maps to `ProtocolLeaveSucceeded { key }`; adapter denial/protocol/network errors map to `ProtocolLeaveFailed { key, failure }`; timer expiry maps to `LeaveTimeout { key, now }`. All three outcome events shall preserve the previous authoritative `current_channel` unless a live delta or snapshot separately confirms `None` or a server-provided replacement. + - Bridge command handlers shall return only command-acceptance or immediate validation/rejection DTOs derived from `JoinReduceStatus`/`JoinErrorCode`. The bridge shall not mutate `current_channel` and shall not transform a successful command send into confirmed membership. + - The bridge event stream shall expose the reducer projection as a DTO equivalent to `ChannelJoinProjection`: `current_channel`, `pending_target`, `pending_since_ms` or timestamp, `can_join`, `can_leave`, `sync_state`, and optional sanitized `last_join_error`. Existing `BridgeEvent::VoiceState` / server-view events may carry these fields directly or by a nested `ChannelJoinState` DTO, but there shall be exactly one authoritative projection source. + - Flutter shall render `pending_target` as non-authoritative pending UI and shall continue to render `current_channel` from the projection as the last confirmed channel. Flutter may cache the projection for rebuild performance but shall not locally set `current_channel` in response to a tap, protocol success DTO, or timer. + - Flutter and bridge DTOs shall not carry, infer, or synthesize a leave target channel id. A leave/move-out UI action is intent-only; the only post-intent membership values Flutter may render are the reducer projection's unchanged prior `current_channel` or a later reducer projection derived from authoritative live delta/snapshot data. +9. UI/view-model behavior: + - `current_channel` displayed in `ChanoraVoiceBar`, channel tree selection, and voice status shall remain the last confirmed authoritative channel until item 4 or item 7 confirms a new authoritative value. + - The pending target may be rendered in `ChanoraChannelTree` as non-authoritative visual feedback (for example spinner, "joining", disabled row action). This visual state shall be derived only from `pending_target` and shall not move the local client row into that channel. + - While `pending_target` is present or `sync_state == Synchronizing`, Flutter shall disable or serialize unsafe channel actions: joining a different target, repeated leave/join churn, and UI operations that assume the pending target is current. Duplicate same-target taps are allowed but coalesced visually. + - Flutter shall not write `current_channel` in local state except as a cached rendering of the reducer-provided authoritative value. +10. Diagnostics and error codes: + - Reducer-visible error/result codes shall include the stable `JoinErrorCode` values from item 2: `DuplicateSameTargetCoalesced`, `JoinAlreadyPendingDifferentTarget`, `JoinDenied`, `JoinProtocolFailure`, `JoinNetworkFailure`, `JoinTimeout`, `JoinSupersededByLeave`, `JoinStaleOutcomeIgnored`, `JoinReconciledDifferentChannel`, `JoinCommandRejectedBeforeSend`, `JoinCannotStartWhileSynchronizing`, `LeaveProtocolFailure`, `LeaveNetworkFailure`, `LeaveTimeout`, `LeaveStaleOutcomeIgnored`, and `LeaveCommandRejectedBeforeSend`. + - Diagnostic event keys shall include `join_requested`, `join_command_sent`, `join_duplicate_coalesced`, `join_confirmed`, `join_failed`, `join_timeout`, `join_stale_outcome_ignored`, `join_pending_superseded_by_leave`, `join_reconnect_synchronizing`, `join_snapshot_reconciled`, `leave_requested`, `leave_command_sent`, `leave_command_succeeded`, `leave_failed`, `leave_timeout`, `leave_stale_outcome_ignored`, and `leave_confirmed_by_authoritative_state`. + - Diagnostic fields are limited to sanitized channel ids, connection epoch, generation, coarse error kind, elapsed milliseconds, and outcome key. No raw protocol payload, channel password, server password, server address, user nickname, or unredacted external error string may be logged. +11. Concurrency and consistency constraints: + - All join/leave/snapshot/live-delta events for one connection shall be serialized through the State Sync reducer task. No UI isolate, bridge callback, protocol task, or audio engine task may concurrently mutate authoritative membership. + - Per-connection event ordering shall be preserved at the reducer input. Cross-connection events are ordered by `connection_epoch`; older epochs cannot mutate newer-epoch state. + - The reducer shall be pure with respect to state transition decisions: timers, protocol sends, bridge emissions, and diagnostics are returned as side-effect actions for the owning runtime to execute. + - Runtime locking rule for Phase B: reducer state mutation may be protected by the existing session/state mutex, but that mutex shall never be held across protocol I/O, timer awaits, bridge emission awaits, or other await points. The owning task shall acquire the lock, reduce the event, copy the returned status/projection/action list and any correlation fields needed by side effects, release the lock, publish projection/execute protocol actions/timers, and then enqueue outcome events (`JoinCommandAccepted`, `JoinCommandRejectedBeforeSend`, `ProtocolJoinSucceeded`, `ProtocolJoinFailed`, `JoinTimeout`, `LeaveCommandAccepted`, `ProtocolLeaveSucceeded`, `ProtocolLeaveFailed`, `LeaveTimeout`) back through the reducer queue. Each outcome is reduced under the lock using the stored `JoinOutcomeKey` or `LeaveOutcomeKey` plus generation/epoch; no side effect may mutate `ChannelJoinState` directly. +12. Verification implications: + - SWE.4 unit cases shall cover the full reducer transition table: no-current/current initial states; normal join; same-target duplicate coalescing; different-target rejection; denied join; timeout; stale success/failure/timeout; authoritative move to target; authoritative move to different channel; leave while pending; leave command accepted/succeeded/failed/timed out without authoritative clearing; stale leave outcomes; authoritative leave confirmed to `None`; authoritative leave reconciled to a server-provided replacement channel; reconnect followed by target snapshot, different-channel snapshot, and no-current snapshot. + - SWE.5 integration cases shall prove Bridge intent-only behavior and Protocol Adapter separation of command success from authoritative membership deltas. SWE.5 shall include a leave/move-out end-to-end seam test proving `voice_leave()` / `leave_channel()` produces exactly one adapter `leave_channel` / `move_out_of_channel` call, produces no `move_to_channel` call with a synthetic target, leaves `current_channel` unchanged after command success/failure/timeout, and updates `current_channel` only after a live self-move/client-leave delta or reconciled snapshot. + - SWE.6 software qualification cases shall prove UI-visible behavior: prior current channel remains visible while joining, pending target is visually marked but not authoritative, unsafe actions are disabled/serialized, failure/timeout preserves prior current channel, and reconnect/snapshot reconciliation clears stale pending state. +13. Authorized implementation phasing: + - Phase A — reducer scaffolding and unit tests: add `chanora_state::channel_join` module, concrete data structures/enums, `reduce`/`project` API, deterministic timer/request-key seams, and SWE.4 reducer tests for same-target coalescing, different-target rejection, stale outcomes, leave supersession, leave outcome non-authoritativeness, and snapshot reconciliation. Phase A may have no externally visible behavior change except compile-time availability of the reducer seam. + - Phase B — core/bridge/Flutter wiring: route existing core join/leave intent and protocol result paths through the reducer queue; add the explicit Protocol Adapter leave/move-out seam; publish `ChannelJoinProjection` through bridge DTO/event mapping; update Flutter view models to render pending target from the projection while preserving authoritative current channel until reducer confirmation. + - Phase C — verification hardening: add SWE.5 integration coverage for bridge intent-only behavior, protocol-success-not-authoritative behavior, explicit leave/move-out seam use with no synthetic channel id, reconnect/snapshot clearing, and timeout/stale-outcome races; add widget coverage for pending target rendering and disabled/serialized unsafe actions. + ## 11. Updated SAD-to-SDD Coverage Matrix | SAD Range | SDD Coverage | @@ -1823,6 +1917,7 @@ Notes: | SAD-063 | Covered by `SDD-118` (Android `chanora_bridge` cdylib build automation — Gradle + `cargo-ndk` + per-ABI jniLibs staging). Cross-trace: SDD-073, SDD-105, SDD-109. | | SAD-061, SAD-062, SAD-087 | Covered by `SDD-119` (iOS / macOS `chanora_bridge` cdylib build automation — CocoaPods podspec + `cargo` + `lipo` + framework-layout shell, back-fill of existing code). Source SAD allocation: iOS half anchored to SAD-061 (iOS runtime) and SAD-062 (App Store / packaging); macOS half anchored to SAD-087 (macOS runtime baseline — deployment target isolation, universal-binary `lipo` packaging, `.framework` `Versions/A` layout, hand-rolled CocoaPods podspec automation), authored in SAD v0.9.7 to close the macOS-runtime anchor gap that this SDD unit had previously bridged by parallelism from SAD-061 / SAD-062. SDD-117 remains reserved-but-unauthored for the deferred `ios_voice_unit` trait back-fill noted by SDD-111. | | SAD-088, SAD-089, SAD-090, SAD-091 | Covered by `SDD-120` (realtime-audio benchmark harness and advisory CI infrastructure — criterion-based bench harness, dhat-backed heap-allocation-count metric, SAD-089 baseline JSON post-processor, SAD-090 advisory PR-comment workflow with merge-base baseline read, SAD-091 `workflow_dispatch`-only baseline-update workflow that opens a PR rather than direct-pushing; simpler-form yellow-marker realization treats the SAD-089 baseline as the comparator for both red/green tolerance evaluation and yellow trending detection). Cross-trace: SDD-094 (the bench seam exercises the same `chanora_audio::engine` capture path whose lifecycle is specified by SDD-094). Suggested SWE.4 forward allocation: SWE4-UV-058 through SWE4-UV-062 (verification-engineer follow-up; not authored by this SDD unit). | +| SAD-092, SAD-093, SAD-094, SAD-095, SAD-096 | Covered by `SDD-121` (channel join pending-state reducer — Rust Core / State Sync authoritative current-channel ownership, Flutter pending-target-only rendering, failure/timeout preservation of previous authoritative channel, explicit Protocol Adapter leave/move-out seam with no synthetic channel id, leave command success/failure/timeout non-authoritative outcome handling, deterministic duplicate/different-target/leave-race/stale-outcome/reconnect-snapshot reconciliation). Upstream software requirements remain traced through SAD coverage only. Suggested SWE.4/SWE.5/SWE.6 derivation surfaces are listed in SDD-121 item 12. | ## Baseline Candidate 0.9.1 Update @@ -1926,6 +2021,30 @@ Notes: | --- | --- | --- | | 0.9.16 | 2026-05-18 | SDD-120 amendment: clarify the post-processor binary placement at `crates/chanora_audio/examples/` rather than `benches/` or `src/bin/`. Rationale: Cargo's dependency resolver only routes `[dev-dependencies]` to `[[test]]`, `[[bench]]`, and `[[example]]` targets; `src/bin/` placement would have forced `serde_json` and other dev-only crates into production builds. Reflects commit 3a7750a discovery. No semantic change to SDD-120 — same harness, same metrics, same workflows, same out-of-scope deferrals; only the path conventions and invocation flags (`--example` not `--bin`) corrected. Edits scoped to SDD-120: (1) "Allocated to" line gains `crates/chanora_audio/examples/` alongside `benches/`; (2) "Software units" list relocates `emit_baseline.rs` and `compare_baseline.rs` from `benches/` to `examples/`; (3) §1 item 4 reworded from `[[bin]]` entries pointing into `benches/` to `[[example]]` entries (auto-discovered under `examples/`) with `cargo run --example` invocation pattern; (4) §2 gains a new item 5 stating the `examples/` rationale and the dev-only isolation property; (5) §5 item 1 path updated and invocation-flag note added; (6) §5 item 4 rationale clause updated to reference the `[dev-dependencies]`-to-examples routing; (7) §6 step 6 and step 8 invocation flags changed `--bin` → `--example`; (8) §7 step 5 invocation flag changed `--bin` → `--example`; (9) §10 item 1 release-artifact-isolation bullet extended with an explicit note that the `examples/` placement is the Cargo-design mechanism enforcing the exclusion of `[dev-dependencies]` (including `serde_json`, `criterion`, `dhat`) from `cargo build --release` and from `flutter build apk/aab/ipa --release`. §11 verification matrix unchanged. Coverage matrix row for SAD-088..SAD-091 unchanged. The SDD-120 spec is otherwise byte-identical to v0.9.15. | +## Baseline Candidate 0.9.20 Update + +| Version | Date | Description | +| --- | --- | --- | +| 0.9.20 | 2026-05-19 | SDD-121 documentation-only final-review amendment. Removed direct SRS identifier enumeration from the SDD-121 source/context field and from the SAD-to-SDD coverage row; upstream software requirements are now described only as traced via SAD coverage. Clarified Phase B join request correlation: use protocol transport ids when available, otherwise allocate a core-local monotonically incremented `JoinRequestId` scoped to the connection epoch after releasing the session lock and before enqueuing `JoinCommandAccepted`; all success/failure/timeout outcomes carry the resulting `JoinOutcomeKey`. Clarified that pending projection publishes immediately after accepted `UserJoinRequested` and before protocol send, session/state mutexes are not held across protocol/timer/bridge awaits, `SendLeaveCommand` actions must be executed, and authoritative current-channel finalization remains limited to authoritative self-move/snapshot confirmation and projections derived from that confirmation. | + +## Baseline Candidate 0.9.21 Update + +| Version | Date | Description | +| --- | --- | --- | +| 0.9.21 | 2026-05-19 | SDD-121 documentation-only amendment for SAD-096. Added SAD-096 to SDD-121 Source SAD and the updated SAD-to-SDD coverage matrix. Specified the explicit Protocol Adapter leave/move-out seam (`ProtocolClient::leave_channel(&self) -> Result<(), ProtocolError>` preferred, `move_out_of_channel` acceptable) with no channel-id argument and no upper-layer synthetic channel id. Updated reducer events/actions with `LeaveRequestId`, `LeaveOutcomeKey`, `LeaveCommandAccepted`, `ProtocolLeaveSucceeded`, `ProtocolLeaveFailed`, `LeaveTimeout`, `StartLeaveTimeout`, and `CancelLeaveTimeout`; mapped leave success/failure/timeout to command-lifecycle diagnostics and non-authoritative projection behavior. Reaffirmed that only live deltas or snapshots may clear or replace authoritative `current_channel`. Added SWE.4/SWE.5 verification notes for leave/move-out behavior and no synthetic `move_to_channel` fallback. | + +## Baseline Candidate 0.9.19 Update + +| Version | Date | Description | +| --- | --- | --- | +| 0.9.19 | 2026-05-18 | Refined SDD-121 to resolve the reducer-surface implementation blocker without code changes. Added concrete `chanora_state::channel_join` module/API design (`ChannelJoinState`, `AuthoritativeMembership`, `JoinPending`, `ChannelJoinEvent`, `ChannelJoinAction`, `ChannelJoinProjection`, `JoinReduceStatus`, `JoinIntentRejected`, `JoinFailureKind`, `JoinErrorCode`, `reduce`, and `project`), explicit core/protocol/bridge/Flutter integration seams, and authorized Phase A/B/C implementation sequencing. Existing SDD-121 behavior decisions are preserved: duplicate same-target coalesces; different target while pending is rejected/serialized for P0; leave supersedes pending; stale outcomes are ignored by epoch/generation/request mismatch; reconnect/snapshot reconciliation clears or confirms pending; protocol success alone is not authoritative finalization. Source SAD remains SAD-092 through SAD-095. | + +## Baseline Candidate 0.9.18 Update + +| Version | Date | Description | +| --- | --- | --- | +| 0.9.18 | 2026-05-18 | Added channel join pending-state detailed design SDD-121 sourced from SAD-092 through SAD-095. The new unit defines the Rust Core / State Sync reducer state (`AuthoritativeMembership`, `JoinPending`, connection epoch/generation, synchronizing state), events, success/failure/timeout/reconnect transitions, bridge/core/protocol boundaries, UI pending-target rendering constraints, sanitized diagnostics/error codes, concurrency assumptions, and SWE.4/SWE.5/SWE.6 verification implications. Behavior selections: duplicate same-target joins coalesce; different-target joins while pending are rejected/serialized for P0; leave while pending supersedes and stales the pending join; stale outcomes are ignored by generation/request/epoch key; reconnect advances epoch and resolves membership from the fresh snapshot. Coverage matrix updated for SAD-092..SAD-095 with upstream software requirements retained only through SAD coverage. | + ## Baseline Candidate 0.9.17 Update | Version | Date | Description |