diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..901eb32 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,40 @@ +# Environment variables set for all cargo invocations in this workspace. +# CMAKE_POLICY_VERSION_MINIMUM is required for audiopus_sys's bundled +# Opus CMake build to succeed on CMake 4.x (which removed compatibility +# with cmake_minimum_required < 3.5). audiopus_sys v0.2.2 bundles +# Opus 1.3.1 whose CMakeLists.txt uses a very old minimum version. +# +# IPHONEOS_DEPLOYMENT_TARGET=13.0 is required for iOS builds because +# ___chkstk_darwin (the stack-probe symbol emitted by clang for functions +# with large stack frames) only exists in the iOS 13.0+ runtime. The +# pre-built libopus.a and any C code compiled by the cc crate reference +# this symbol. Without this env var, the cc crate compiles C code +# targeting iOS 10.0 (the Rust default), producing object files that +# reference ___chkstk_darwin but can't resolve it against the 10.0 +# runtime. Setting IPHONEOS_DEPLOYMENT_TARGET=13.0 ensures the cc crate +# and CMake both target iOS 13.0+, where ___chkstk_darwin exists. +# (DEC-003: minimum deployment target iOS 13.0) +[env] +CMAKE_POLICY_VERSION_MINIMUM = "3.5" +IPHONEOS_DEPLOYMENT_TARGET = "13.0" + +# iOS target linker flags (DEC-003: minimum deployment target iOS 13.0). +# +# These rustflags pass -miphoneos-version-min=13.0 to the linker, ensuring +# the final binary targets iOS 13.0+. This is defense-in-depth alongside +# the IPHONEOS_DEPLOYMENT_TARGET env var above — the env var affects C +# compilation (cc crate, CMake), while these rustflags affect the final +# link step. +# +# NOTE: The canonical iOS build is done via tools/build-ios.sh, which +# sets LIBOPUS_STATIC=1, LIBOPUS_NO_PKG=1, and LIBOPUS_LIB_DIR to +# bypass audiopus_sys's CMake build entirely. + +[target.aarch64-apple-ios] +rustflags = ["-C", "link-arg=-miphoneos-version-min=13.0"] + +[target.aarch64-apple-ios-sim] +rustflags = ["-C", "link-arg=-miphonesimulator-version-min=13.0"] + +[target.x86_64-apple-ios] +rustflags = ["-C", "link-arg=-miphonesimulator-version-min=13.0"] diff --git a/.gitignore b/.gitignore index 825858c..4a40d5b 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,5 @@ opencode.json # iOS framework build artifacts produced by chanora_bridge.podspec /apps/chanora_flutter/ios/Frameworks/ +.opencode/ +AGENTS.md diff --git a/apps/chanora_flutter/ios/Runner/AppDelegate.swift b/apps/chanora_flutter/ios/Runner/AppDelegate.swift index ce11818..d6e1820 100644 --- a/apps/chanora_flutter/ios/Runner/AppDelegate.swift +++ b/apps/chanora_flutter/ios/Runner/AppDelegate.swift @@ -4,6 +4,8 @@ import AVFoundation @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + private var iosAudioLifecycleChannel: FlutterMethodChannel? + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -98,7 +100,10 @@ import AVFoundation // output-only Bluetooth devices. options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP] ) - NSLog("chanora_flutter: AVAudioSession category set (playAndRecord/default + defaultToSpeaker)") + // Match VPIO / Opus frame cadence to reduce callback pressure. + try session.setPreferredIOBufferDuration(0.02) + try session.setPreferredSampleRate(48000.0) + logAudioSessionState(context: "setCategory") } catch { NSLog("chanora_flutter: AVAudioSession setCategory failed: \(error)") } @@ -115,6 +120,20 @@ import AVFoundation object: nil ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleRouteChange(_:)), + name: AVAudioSession.routeChangeNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleInterruption(_:)), + name: AVAudioSession.interruptionNotification, + object: nil + ) + // Request microphone access on first launch rather than waiting // for the user's first voice-channel join. The latter is // surprising: the user has only tapped "connect to server" and @@ -161,17 +180,13 @@ import AVFoundation // 44.1 kHz (which would explain the user's broken playback // \u2014 our render callback would be writing samples at the // wrong rate, causing pitch + timing artifacts). + logAudioSessionState(context: "setActive") let s = AVAudioSession.sharedInstance() - let route = s.currentRoute - let outs = route.outputs.map { "\($0.portType.rawValue)/\($0.portName)" }.joined(separator: ",") - let ins = route.inputs.map { "\($0.portType.rawValue)/\($0.portName)" }.joined(separator: ",") + let ins = s.currentRoute.inputs.map { "\($0.portType.rawValue)/\($0.portName)" }.joined(separator: ",") NSLog( "chanora_flutter: AVAudioSession actual: " + - "category=\(s.category.rawValue) " + - "mode=\(s.mode.rawValue) " + "sampleRate=\(s.sampleRate) " + "ioBufferDuration=\(String(format: "%.4f", s.ioBufferDuration)) " + - "outputs=[\(outs)] " + "inputs=[\(ins)] " + "outputVolume=\(s.outputVolume)" ) @@ -180,7 +195,65 @@ import AVFoundation } } + /// Reads back the actual AVAudioSession state and logs it for + /// SDD-098 compliance. Called after both setCategory and setActive + /// to verify that the session accepted the requested configuration. + private func logAudioSessionState(context: String) { + let s = AVAudioSession.sharedInstance() + NSLog("chanora_flutter: [\(context)] category=\(s.category.rawValue) mode=\(s.mode.rawValue) options=\(s.categoryOptions.rawValue) route outputs=\(s.currentRoute.outputs.map { "\($0.portType.rawValue)" })") + if s.sampleRate != 48000.0 { + NSLog("chanora_flutter: WARNING: actual sample rate \(s.sampleRate) != requested 48000") + } + if s.ioBufferDuration > 0.025 { + NSLog("chanora_flutter: WARNING: IO buffer duration \(s.ioBufferDuration) > 25ms, may cause latency") + } + } + + @objc private func handleRouteChange(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, + let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) + else { + return + } + + let routeDescription = AVAudioSession.sharedInstance().currentRoute + let outputs = routeDescription.outputs.map { $0.portType.rawValue }.joined(separator: ",") + NSLog("chanora_flutter: route change reason=\(reason.rawValue) outputs=\(outputs)") + + if reason == .oldDeviceUnavailable || reason == .newDeviceAvailable { + iosAudioLifecycleChannel?.invokeMethod("handleRouteChange", arguments: nil) + } + } + + @objc private func handleInterruption(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) + else { + return + } + + switch type { + case .began: + NSLog("chanora_flutter: audio interruption began") + iosAudioLifecycleChannel?.invokeMethod("handleInterruptionBegan", arguments: nil) + case .ended: + let shouldResume = (userInfo[AVAudioSessionInterruptionOptionKey] as? UInt) + .map { $0 & AVAudioSession.InterruptionOptions.shouldResume.rawValue != 0 } + ?? false + NSLog("chanora_flutter: audio interruption ended shouldResume=\(shouldResume)") + iosAudioLifecycleChannel?.invokeMethod("handleInterruptionEnded", arguments: shouldResume) + @unknown default: + break + } + } + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + iosAudioLifecycleChannel = FlutterMethodChannel( + name: "chanora/ios_audio_lifecycle", + binaryMessenger: engineBridge.applicationRegistrar.messenger() + ) } } diff --git a/apps/chanora_flutter/lib/l10n/app_en.arb b/apps/chanora_flutter/lib/l10n/app_en.arb index 2237c4b..b150c0a 100644 --- a/apps/chanora_flutter/lib/l10n/app_en.arb +++ b/apps/chanora_flutter/lib/l10n/app_en.arb @@ -52,6 +52,7 @@ "pttCapabilityExplainGoGlobalWindows": "On Windows, Global PTT is engaged automatically once you bind a key. No additional permission is required.", "pttCapabilityExplainGoGlobalMacos": "On macOS, Global PTT requires Input Monitoring permission. Open System Settings → Privacy & Security → Input Monitoring, allow Chanora, then re-bind the key.", "pttCapabilityExplainGoGlobalLinux": "On Linux, Global PTT requires a GNOME-Wayland desktop with the GlobalShortcuts portal. Re-bind the key and accept the desktop's shortcut dialog when it appears.", + "pttCapabilityExplainGoGlobalIos": "On iOS, Apple does not expose global hotkeys to apps. Chanora uses the on-screen Push-to-Talk button and only transmits while the app is in the foreground.", "pttCapabilityExplainGoGlobalGeneric": "Global PTT is not available in this environment. Focused PTT will keep working while the Chanora window has focus.", "pttConfigureAction": "Configure", "pttConfigureTitle": "Configure Push-to-Talk binding", @@ -66,6 +67,7 @@ "outputMuteAction": "Mute speaker", "outputUnmuteAction": "Unmute speaker", "joinChannelAction": "Join channel", + "leaveChannelAction": "Leave channel", "channelPasswordTitle": "Channel password", "bookmarksHeading": "Bookmarks", "bookmarksEmpty": "No bookmarks yet. Enter a server above and tap \"Save bookmark\".", @@ -140,6 +142,7 @@ "audioRouteCarAudio": "Car audio", "audioRouteAirplay": "AirPlay", "audioRouteUnknown": "Unknown", + "channelJoinAlreadyIn": "Already in this channel.", "channelJoinFailedPermission": "Insufficient permission to join this channel.", "channelJoinFailedPassword": "Wrong channel password.", "channelJoinFailedFull": "Channel is full.", diff --git a/apps/chanora_flutter/lib/l10n/app_zh.arb b/apps/chanora_flutter/lib/l10n/app_zh.arb index e0cd9f4..886d024 100644 --- a/apps/chanora_flutter/lib/l10n/app_zh.arb +++ b/apps/chanora_flutter/lib/l10n/app_zh.arb @@ -37,6 +37,7 @@ "pttCapabilityExplainGoGlobalWindows": "在 Windows 上,绑定按键后会自动启用全局对讲,无需额外权限。", "pttCapabilityExplainGoGlobalMacos": "在 macOS 上,启用全局对讲需要「输入监视」权限。请打开「系统设置 → 隐私与安全 → 输入监视」,授权 Chanora 后重新绑定按键。", "pttCapabilityExplainGoGlobalLinux": "在 Linux 上,启用全局对讲需要带 GlobalShortcuts 门户的 GNOME-Wayland 桌面。请重新绑定按键,并在桌面弹出快捷键对话框时接受。", + "pttCapabilityExplainGoGlobalIos": "在 iOS 上,Apple 不向应用开放全局热键。Chanora 使用屏幕上的按住说话按钮,并且仅在应用位于前台时响应。", "pttCapabilityExplainGoGlobalGeneric": "当前环境暂不支持全局对讲。聚焦对讲在 Chanora 窗口获得焦点时仍可正常使用。", "pttConfigureAction": "配置", "pttConfigureTitle": "配置对讲按键", @@ -51,6 +52,7 @@ "outputMuteAction": "静音扬声器", "outputUnmuteAction": "取消扬声器静音", "joinChannelAction": "加入频道", + "leaveChannelAction": "离开频道", "channelPasswordTitle": "频道密码", "bookmarksHeading": "书签", "bookmarksEmpty": "尚无书签。先在上方填写服务器,然后点击“保存书签”。", @@ -97,6 +99,7 @@ "audioRouteCarAudio": "车载音频", "audioRouteAirplay": "AirPlay", "audioRouteUnknown": "未知", + "channelJoinAlreadyIn": "您已在此频道中。", "channelJoinFailedPermission": "权限不足,无法加入此频道。", "channelJoinFailedPassword": "频道密码错误。", "channelJoinFailedFull": "频道已满。", diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart index 1ff389a..ab6d1c0 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart @@ -295,6 +295,12 @@ abstract class AppL10n { /// **'On Linux, Global PTT requires a GNOME-Wayland desktop with the GlobalShortcuts portal. Re-bind the key and accept the desktop\'s shortcut dialog when it appears.'** String get pttCapabilityExplainGoGlobalLinux; + /// No description provided for @pttCapabilityExplainGoGlobalIos. + /// + /// In en, this message translates to: + /// **'On iOS, Apple does not expose global hotkeys to apps. Chanora uses the on-screen Push-to-Talk button and only transmits while the app is in the foreground.'** + String get pttCapabilityExplainGoGlobalIos; + /// No description provided for @pttCapabilityExplainGoGlobalGeneric. /// /// In en, this message translates to: @@ -379,6 +385,12 @@ abstract class AppL10n { /// **'Join channel'** String get joinChannelAction; + /// No description provided for @leaveChannelAction. + /// + /// In en, this message translates to: + /// **'Leave channel'** + String get leaveChannelAction; + /// No description provided for @channelPasswordTitle. /// /// In en, this message translates to: @@ -625,6 +637,12 @@ abstract class AppL10n { /// **'Unknown'** String get audioRouteUnknown; + /// No description provided for @channelJoinAlreadyIn. + /// + /// In en, this message translates to: + /// **'Already in this channel.'** + String get channelJoinAlreadyIn; + /// No description provided for @channelJoinFailedPermission. /// /// In en, this message translates to: diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart index b06d1be..67c680c 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart @@ -123,6 +123,10 @@ class AppL10nEn extends AppL10n { String get pttCapabilityExplainGoGlobalLinux => 'On Linux, Global PTT requires a GNOME-Wayland desktop with the GlobalShortcuts portal. Re-bind the key and accept the desktop\'s shortcut dialog when it appears.'; + @override + String get pttCapabilityExplainGoGlobalIos => + 'On iOS, Apple does not expose global hotkeys to apps. Chanora uses the on-screen Push-to-Talk button and only transmits while the app is in the foreground.'; + @override String get pttCapabilityExplainGoGlobalGeneric => 'Global PTT is not available in this environment. Focused PTT will keep working while the Chanora window has focus.'; @@ -169,6 +173,9 @@ class AppL10nEn extends AppL10n { @override String get joinChannelAction => 'Join channel'; + @override + String get leaveChannelAction => 'Leave channel'; + @override String get channelPasswordTitle => 'Channel password'; @@ -305,6 +312,9 @@ class AppL10nEn extends AppL10n { @override String get audioRouteUnknown => 'Unknown'; + @override + String get channelJoinAlreadyIn => 'Already in this channel.'; + @override String get channelJoinFailedPermission => 'Insufficient permission to join this channel.'; diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart index 6f61208..7290070 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart @@ -120,6 +120,10 @@ class AppL10nZh extends AppL10n { String get pttCapabilityExplainGoGlobalLinux => '在 Linux 上,启用全局对讲需要带 GlobalShortcuts 门户的 GNOME-Wayland 桌面。请重新绑定按键,并在桌面弹出快捷键对话框时接受。'; + @override + String get pttCapabilityExplainGoGlobalIos => + '在 iOS 上,Apple 不向应用开放全局热键。Chanora 使用屏幕上的按住说话按钮,并且仅在应用位于前台时响应。'; + @override String get pttCapabilityExplainGoGlobalGeneric => '当前环境暂不支持全局对讲。聚焦对讲在 Chanora 窗口获得焦点时仍可正常使用。'; @@ -164,6 +168,9 @@ class AppL10nZh extends AppL10n { @override String get joinChannelAction => '加入频道'; + @override + String get leaveChannelAction => '离开频道'; + @override String get channelPasswordTitle => '频道密码'; @@ -299,6 +306,9 @@ class AppL10nZh extends AppL10n { @override String get audioRouteUnknown => '未知'; + @override + String get channelJoinAlreadyIn => '您已在此频道中。'; + @override String get channelJoinFailedPermission => '权限不足,无法加入此频道。'; diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index e5fd2ea..8a099c0 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -67,9 +67,43 @@ Future main() async { await _resolveAppVersion(); unawaited(_wireStorage()); unawaited(_wireConnectivity()); + _wireIosAudioLifecycle(); runApp(const ChanoraApp()); } +/// Wire the iOS AVAudioSession lifecycle MethodChannel. +/// +/// Swift side (AppDelegate) posts route-change and interruption +/// events through `FlutterMethodChannel` named +/// `"chanora/ios_audio_lifecycle"`. This handler dispatches them to +/// the FRB bridge functions on the Rust side. +void _wireIosAudioLifecycle() { + const channel = MethodChannel('chanora/ios_audio_lifecycle'); + channel.setMethodCallHandler((call) async { + try { + switch (call.method) { + case 'handleRouteChange': + rust.handleRouteChange(); + break; + case 'handleInterruptionBegan': + rust.handleInterruptionBegan(); + break; + case 'handleInterruptionEnded': + // `shouldResume` is passed from Swift as a bool argument. + final shouldResume = call.arguments as bool? ?? false; + rust.handleInterruptionEnded(shouldResume: shouldResume); + break; + default: + // Unknown method — ignore gracefully rather than crashing. + break; + } + } catch (_) { + // Errors from the Rust side are already logged there; + // don't propagate exceptions to the iOS framework. + } + }); +} + /// Populate `_kAppVersion` by suffixing the platform-canonical /// build number to `_kSemverBaseline`. Format: /// `v1.0.0-rc.8+` (e.g. `v1.0.0-rc.8+64`). The build @@ -186,6 +220,12 @@ class _BetaHomeState extends State<_BetaHome> { String _pttLevel = 'L0Focused'; String _pttBackendId = 'focused'; String _pttBoundInputClass = 'keyboard'; + // iOS interruption state from BridgeEvent::InterruptionState + // (SDD-101). Can be used by UI surfaces (banner/snackbar). + // ignore: unused_field + bool _iosAudioInterrupted = false; + // ignore: unused_field + bool _iosInterruptionShouldResume = false; // Last platform-neutral key label the user saved in the // `_PttBindingCaptureDialog` (e.g. "Space", "F10", // "mouse-side-button:8"). Surfaced next to the capability @@ -272,21 +312,21 @@ class _BetaHomeState extends State<_BetaHome> { case rust.BridgeEvent_SnapshotChanged(): unawaited(_onRefresh()); case rust.BridgeEvent_PttCapability( - :final level, - :final backendId, - :final boundInputClass, - ): + :final level, + :final backendId, + :final boundInputClass, + ): setState(() { _pttLevel = level; _pttBackendId = backendId; _pttBoundInputClass = boundInputClass; }); case rust.BridgeEvent_VoiceState( - :final inChannel, - :final transmitMode, - :final mute, - :final releaseTailMs, - ): + :final inChannel, + :final transmitMode, + :final mute, + :final releaseTailMs, + ): setState(() { _inChannel = inChannel; _transmitMode = transmitMode; @@ -300,6 +340,39 @@ class _BetaHomeState extends State<_BetaHome> { _statsTimer?.cancel(); _statsTimer = null; } + case rust.BridgeEvent_InterruptionState( + :final began, + :final shouldResume, + ): + setState(() { + _iosAudioInterrupted = began; + _iosInterruptionShouldResume = shouldResume; + }); + // Surface iOS audio interruption to the user (SDD-101). + // Use unawaited to stay inside the sync _onEvent stream + // without blocking it. + if (!mounted) return; + unawaited(() async { + if (!mounted) return; + final messenger = ScaffoldMessenger.of(context); + if (began) { + messenger.showSnackBar( + const SnackBar( + content: Text('Audio interrupted by system (phone call)'), + duration: Duration(seconds: 3), + backgroundColor: Colors.orange, + ), + ); + } else if (shouldResume) { + messenger.showSnackBar( + const SnackBar( + content: Text('Audio resuming'), + duration: Duration(seconds: 2), + backgroundColor: Colors.green, + ), + ); + } + }()); } } @@ -410,6 +483,7 @@ class _BetaHomeState extends State<_BetaHome> { } Future _onJoinChannel(rust.BridgeChannel ch) async { + if (ch.id == _currentVoiceChannelId) return; final l10n = AppL10n.of(context); final messenger = ScaffoldMessenger.of(context); String? password; @@ -419,10 +493,7 @@ class _BetaHomeState extends State<_BetaHome> { if (password == null) return; // cancelled } try { - await rust.voiceJoin( - channelId: ch.id, - password: password ?? '', - ); + await rust.voiceJoin(channelId: ch.id, password: password ?? ''); if (!mounted) return; setState(() => _currentVoiceChannelId = ch.id); } catch (e) { @@ -455,7 +526,8 @@ class _BetaHomeState extends State<_BetaHome> { l10n.channelJoinFailedGeneric('$host: $reason'), connection: (msg) => l10n.channelJoinFailedGeneric(msg), notConnected: () => l10n.channelJoinFailedGeneric('not connected'), - alreadyConnected: () => l10n.channelJoinFailedGeneric('already connected'), + alreadyConnected: () => + l10n.channelJoinFailedGeneric('already connected'), serverRejected: (code, message) { // Canonical TS3 error codes per ReSpeak/tsdeclarations // Errors.csv. @@ -465,6 +537,8 @@ class _BetaHomeState extends State<_BetaHome> { return l10n.channelJoinFailedTimeout; case 0x0a08: // permissions_client_insufficient return l10n.channelJoinFailedPermission; + case 0x0302: // channel_already_in + return l10n.channelJoinAlreadyIn; case 0x030d: // channel_invalid_password return l10n.channelJoinFailedPassword; case 0x0309: // channel_maxclients_reached @@ -702,9 +776,9 @@ class _BetaHomeState extends State<_BetaHome> { if (_pttBackendId == 'gnome-wayland-portal') { try { final messenger = ScaffoldMessenger.of(context); - messenger.showSnackBar(SnackBar( - content: Text(l10n.pttConfigurePortalRedirect), - )); + messenger.showSnackBar( + SnackBar(content: Text(l10n.pttConfigurePortalRedirect)), + ); await rust.setPttBinding( inputClass: rust.BridgePttInputClass.keyboard, platformKey: 'portal', @@ -712,9 +786,9 @@ class _BetaHomeState extends State<_BetaHome> { } catch (e) { if (!mounted) return; final messenger = ScaffoldMessenger.of(this.context); - messenger.showSnackBar(SnackBar( - content: Text(l10n.statusError(e.toString())), - )); + messenger.showSnackBar( + SnackBar(content: Text(l10n.statusError(e.toString()))), + ); } return; } @@ -750,9 +824,9 @@ class _BetaHomeState extends State<_BetaHome> { // Use the State's context (guaranteed valid because we // re-checked `mounted` immediately above). final messenger = ScaffoldMessenger.of(this.context); - messenger.showSnackBar(SnackBar( - content: Text(l10n.statusError(e.toString())), - )); + messenger.showSnackBar( + SnackBar(content: Text(l10n.statusError(e.toString()))), + ); } } @@ -773,40 +847,25 @@ class _BetaHomeState extends State<_BetaHome> { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text( - l10n.appTitle, - style: theme.textTheme.titleLarge, - ), + Text(l10n.appTitle, style: theme.textTheme.titleLarge), const SizedBox(height: 4), Text( l10n.aboutVersion(_kAppVersion), style: theme.textTheme.bodySmall, ), const SizedBox(height: 16), - Text( - l10n.aboutNonAffiliation, - style: theme.textTheme.bodyMedium, - ), + Text(l10n.aboutNonAffiliation, style: theme.textTheme.bodyMedium), const SizedBox(height: 12), - Text( - l10n.aboutLicenseHeading, - style: theme.textTheme.titleSmall, - ), + Text(l10n.aboutLicenseHeading, style: theme.textTheme.titleSmall), const SizedBox(height: 4), - Text( - l10n.aboutLicenseBody, - style: theme.textTheme.bodySmall, - ), + Text(l10n.aboutLicenseBody, style: theme.textTheme.bodySmall), const SizedBox(height: 12), Text( l10n.aboutThirdPartyHeading, style: theme.textTheme.titleSmall, ), const SizedBox(height: 4), - Text( - l10n.aboutThirdPartyBody, - style: theme.textTheme.bodySmall, - ), + Text(l10n.aboutThirdPartyBody, style: theme.textTheme.bodySmall), ], ), ), @@ -879,11 +938,7 @@ class _BetaHomeState extends State<_BetaHome> { _hostCtl.text = b.host; _nickCtl.text = b.nickname; _passwordCtl.text = b.password; - await _onConnect( - host: b.host, - nickname: b.nickname, - password: b.password, - ); + await _onConnect(host: b.host, nickname: b.nickname, password: b.password); } @override @@ -994,191 +1049,191 @@ class _BetaHomeState extends State<_BetaHome> { ), ); return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!isWideSnapshot) ...[ - banner, - const SizedBox(height: 12), - ], - Text(statusText(), style: theme.textTheme.titleMedium), - if (_lostReason != null || _reconnectAttempt != null) ...[ - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: theme.colorScheme.errorContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: theme.colorScheme.onErrorContainer, - ), + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)], + Text(statusText(), style: theme.textTheme.titleMedium), + if (_lostReason != null || _reconnectAttempt != null) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer, + borderRadius: BorderRadius.circular(8), ), - const SizedBox(width: 10), - Expanded( - child: Text( - _reconnectAttempt != null - ? l10n.statusReconnecting( - _reconnectAttempt!, - _reconnectDelay ?? 0, - ) - : l10n.statusConnectionLost(_lostReason ?? ''), - style: TextStyle( - color: theme.colorScheme.onErrorContainer, + child: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.onErrorContainer, + ), ), - ), - ), - ], - ), - ), - ], - const SizedBox(height: 12), - if (_phase == _Phase.idle) ...[ - Expanded( - child: SingleChildScrollView( - // Dismiss keyboard when the user drags away from - // a focused field — friendlier mobile UX than - // forcing them to tap outside. - keyboardDismissBehavior: - ScrollViewKeyboardDismissBehavior.onDrag, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _ConnectForm( - hostCtl: _hostCtl, - nickCtl: _nickCtl, - passwordCtl: _passwordCtl, - onConnect: () => _onConnect(), - onAddBookmark: _onAddCurrentBookmark, - ), - const SizedBox(height: 16), - _BookmarkList( - bookmarks: _bookmarks, - onConnect: _onUseBookmark, - onDelete: _onDeleteBookmark, - ), - ], - ), - ), - ), - ] else if (_phase == _Phase.connecting) ...[ - const Center( - child: Padding( - padding: EdgeInsets.all(32), - child: CircularProgressIndicator(), - ), - ), - ] else if (_phase == _Phase.connected && _snapshot != null) ...[ - Expanded( - child: LayoutBuilder( - builder: (ctx, constraints) { - final voiceBar = VoiceBar( - inChannel: _inChannel, - transmitMode: _transmitMode, - hardMute: _hardMute, - outputMuted: _outputMuted, - releaseTailMs: _releaseTailMs, - channelName: _currentVoiceChannelName(), - audioStats: _audioStats, - pttLevel: _pttLevel, - pttBackendId: _pttBackendId, - pttBoundInputClass: _pttBoundInputClass, - pttBoundKeyLabel: _pttBoundKeyLabel, - onToggleMute: _onToggleHardMute, - onToggleOutputMute: _toggleOutputMute, - onConfigure: _onOpenVoiceSettings, - onPttHeldChanged: _onOnscreenPttHeldChanged, - ); - final snapshotView = _SnapshotView( - snapshot: _snapshot!, - onJoinChannel: _onJoinChannel, - ); - // Responsive: at <840 dp use a stacked layout - // (Voice Bar on top, channel tree below). At - // ≥840 dp use a side-by-side layout with the - // Voice Bar pinned to 320 dp on the left and - // the channel tree expanding on the right. - // 840 dp matches Material's tablet / desktop - // breakpoint. - const wideBreakpoint = 840.0; - const voiceBarWidthWide = 320.0; - if (constraints.maxWidth >= wideBreakpoint) { - return Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox( - width: voiceBarWidthWide, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - banner, - const SizedBox(height: 12), - voiceBar, - ], + const SizedBox(width: 10), + Expanded( + child: Text( + _reconnectAttempt != null + ? l10n.statusReconnecting( + _reconnectAttempt!, + _reconnectDelay ?? 0, + ) + : l10n.statusConnectionLost(_lostReason ?? ''), + style: TextStyle( + color: theme.colorScheme.onErrorContainer, ), ), - const SizedBox(width: 12), - Expanded(child: snapshotView), - ], - ); - } - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // In narrow / one-column layout the layout - // is: - // - // [ channel tree (Expanded) ] - // [ status chip (2-line live readout) ] - // [ PTT button (mobile-only, - // PTT-mode-only) ] - // - // The chip is a tap target that opens - // `showVoiceDetailsSheet` with the mic - // level meter + TX/RX counts + capability - // badge. Mode + bind key + release-tail - // live in `VoiceSettingsDialog` - // (gear icon in AppBar). - // - // Wide mode (Row branch above) is - // unchanged from rc.8. - Expanded(child: snapshotView), - const SizedBox(height: 8), - VoiceStatusChip( - transmitMode: _transmitMode, - releaseTailMs: _releaseTailMs, - pttBoundKeyLabel: _pttBoundKeyLabel, - audioStats: _audioStats, - isTouchOnly: _isTouchOnlyPttHost, - onTap: () => _onOpenVoiceDetailsSheet(), ), - // PTT button only when PTT mode is active - // AND the user is in a voice channel. In - // Continuous mode there is nothing to - // hold; the chip alone surfaces the - // "Mic on / off" state. - if (_inChannel && - _transmitMode == - rust.BridgeTransmitMode.ptt) ...[ - const SizedBox(height: 8), - VoicePttButton( - active: _audioStats?.pttActive ?? false, - onHeldChanged: _onOnscreenPttHeldChanged, + ], + ), + ), + ], + const SizedBox(height: 12), + if (_phase == _Phase.idle) ...[ + Expanded( + child: SingleChildScrollView( + // Dismiss keyboard when the user drags away from + // a focused field — friendlier mobile UX than + // forcing them to tap outside. + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _ConnectForm( + hostCtl: _hostCtl, + nickCtl: _nickCtl, + passwordCtl: _passwordCtl, + onConnect: () => _onConnect(), + onAddBookmark: _onAddCurrentBookmark, + ), + const SizedBox(height: 16), + _BookmarkList( + bookmarks: _bookmarks, + onConnect: _onUseBookmark, + onDelete: _onDeleteBookmark, ), ], - ], - ); - }, - ), - ), - ], - ], - ); + ), + ), + ), + ] else if (_phase == _Phase.connecting) ...[ + const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: CircularProgressIndicator(), + ), + ), + ] else if (_phase == _Phase.connected && _snapshot != null) ...[ + Expanded( + child: LayoutBuilder( + builder: (ctx, constraints) { + final voiceBar = VoiceBar( + inChannel: _inChannel, + transmitMode: _transmitMode, + hardMute: _hardMute, + outputMuted: _outputMuted, + releaseTailMs: _releaseTailMs, + channelName: _currentVoiceChannelName(), + audioStats: _audioStats, + pttLevel: _pttLevel, + pttBackendId: _pttBackendId, + pttBoundInputClass: _pttBoundInputClass, + pttBoundKeyLabel: _pttBoundKeyLabel, + onToggleMute: _onToggleHardMute, + onToggleOutputMute: _toggleOutputMute, + onConfigure: _onOpenVoiceSettings, + onPttHeldChanged: _onOnscreenPttHeldChanged, + ); + final snapshotView = _SnapshotView( + snapshot: _snapshot!, + currentVoiceChannelId: _currentVoiceChannelId, + onJoinChannel: _onJoinChannel, + onLeaveVoice: _onLeaveVoice, + ); + // Responsive: at <840 dp use a stacked layout + // (Voice Bar on top, channel tree below). At + // ≥840 dp use a side-by-side layout with the + // Voice Bar pinned to 320 dp on the left and + // the channel tree expanding on the right. + // 840 dp matches Material's tablet / desktop + // breakpoint. + const wideBreakpoint = 840.0; + const voiceBarWidthWide = 320.0; + if (constraints.maxWidth >= wideBreakpoint) { + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: voiceBarWidthWide, + child: Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + banner, + const SizedBox(height: 12), + voiceBar, + ], + ), + ), + const SizedBox(width: 12), + Expanded(child: snapshotView), + ], + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // In narrow / one-column layout the layout + // is: + // + // [ channel tree (Expanded) ] + // [ status chip (2-line live readout) ] + // [ PTT button (mobile-only, + // PTT-mode-only) ] + // + // The chip is a tap target that opens + // `showVoiceDetailsSheet` with the mic + // level meter + TX/RX counts + capability + // badge. Mode + bind key + release-tail + // live in `VoiceSettingsDialog` + // (gear icon in AppBar). + // + // Wide mode (Row branch above) is + // unchanged from rc.8. + Expanded(child: snapshotView), + const SizedBox(height: 8), + VoiceStatusChip( + transmitMode: _transmitMode, + releaseTailMs: _releaseTailMs, + pttBoundKeyLabel: _pttBoundKeyLabel, + audioStats: _audioStats, + isTouchOnly: _isTouchOnlyPttHost, + onTap: () => _onOpenVoiceDetailsSheet(), + ), + // PTT button only when PTT mode is active + // AND the user is in a voice channel. In + // Continuous mode there is nothing to + // hold; the chip alone surfaces the + // "Mic on / off" state. + if (_inChannel && + _transmitMode == + rust.BridgeTransmitMode.ptt) ...[ + const SizedBox(height: 8), + VoicePttButton( + active: _audioStats?.pttActive ?? false, + onHeldChanged: _onOnscreenPttHeldChanged, + ), + ], + ], + ); + }, + ), + ), + ], + ], + ); }, ), ), @@ -1225,10 +1280,7 @@ class _AppBarTitle extends StatelessWidget { return Row( mainAxisSize: MainAxisSize.min, children: [ - if (!isNarrow) ...[ - Text(l10n.appTitle), - const SizedBox(width: 12), - ], + if (!isNarrow) ...[Text(l10n.appTitle), const SizedBox(width: 12)], Flexible( child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), @@ -1288,8 +1340,9 @@ class _BookmarkNameDialog extends StatefulWidget { } class _BookmarkNameDialogState extends State<_BookmarkNameDialog> { - late final TextEditingController _ctl = - TextEditingController(text: widget.initialName); + late final TextEditingController _ctl = TextEditingController( + text: widget.initialName, + ); @override void dispose() { @@ -1330,8 +1383,7 @@ class _ChannelPasswordDialog extends StatefulWidget { const _ChannelPasswordDialog(); @override - State<_ChannelPasswordDialog> createState() => - _ChannelPasswordDialogState(); + State<_ChannelPasswordDialog> createState() => _ChannelPasswordDialogState(); } class _ChannelPasswordDialogState extends State<_ChannelPasswordDialog> { @@ -1624,10 +1676,7 @@ class _BookmarkList extends StatelessWidget { if (bookmarks.isEmpty) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8), - child: Text( - l10n.bookmarksEmpty, - style: theme.textTheme.bodySmall, - ), + child: Text(l10n.bookmarksEmpty, style: theme.textTheme.bodySmall), ); } return Column( @@ -1663,7 +1712,6 @@ class _BookmarkList extends StatelessWidget { } } - /// PTT capability badge (gen2 v0.9.3 / SDD-091). /// /// Renders the active PTT level + backend in the Voice Bar so the @@ -1710,6 +1758,8 @@ class PttCapabilityBadge extends StatelessWidget { return l10n.pttCapabilityExplainGoGlobalMacos; case TargetPlatform.linux: return l10n.pttCapabilityExplainGoGlobalLinux; + case TargetPlatform.iOS: + return l10n.pttCapabilityExplainGoGlobalIos; default: return l10n.pttCapabilityExplainGoGlobalGeneric; } @@ -1816,11 +1866,15 @@ class PttCapabilityBadge extends StatelessWidget { class _SnapshotView extends StatelessWidget { const _SnapshotView({ required this.snapshot, + required this.currentVoiceChannelId, required this.onJoinChannel, + required this.onLeaveVoice, }); final rust.BridgeSnapshot snapshot; + final BigInt? currentVoiceChannelId; final ValueChanged onJoinChannel; + final VoidCallback onLeaveVoice; @override Widget build(BuildContext context) { @@ -1876,7 +1930,10 @@ class _SnapshotView extends StatelessWidget { color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(6), ), - child: Text(snapshot.welcomeMessage, style: theme.textTheme.bodySmall), + child: Text( + snapshot.welcomeMessage, + style: theme.textTheme.bodySmall, + ), ), ], const Divider(height: 24), @@ -1889,15 +1946,26 @@ class _SnapshotView extends StatelessWidget { ), child: ListTile( dense: true, - leading: const Icon(Icons.tag), + leading: Icon( + ch.id == currentVoiceChannelId ? Icons.volume_up : Icons.tag, + ), title: Text(ch.name), subtitle: Text('id=${ch.id} parent=${ch.parent}'), trailing: IconButton( - icon: const Icon(Icons.login), - tooltip: l10n.joinChannelAction, - onPressed: () => onJoinChannel(ch), + icon: Icon( + ch.id == currentVoiceChannelId ? Icons.logout : Icons.login, + ), + tooltip: ch.id == currentVoiceChannelId + ? l10n.leaveChannelAction + : l10n.joinChannelAction, + onPressed: ch.id == currentVoiceChannelId + ? onLeaveVoice + : () => onJoinChannel(ch), ), - onTap: () => onJoinChannel(ch), + selected: ch.id == currentVoiceChannelId, + onTap: ch.id == currentVoiceChannelId + ? null + : () => onJoinChannel(ch), ), ), for (final cl in byChannel[ch.id] ?? const []) @@ -2121,11 +2189,11 @@ class _PttBindingCaptureDialogState extends State<_PttBindingCaptureDialog> { onPressed: _captured == null ? null : () => Navigator.of(context).pop( - _CapturedBinding( - inputClass: _capturedClass, - platformKey: _captured!, - ), + _CapturedBinding( + inputClass: _capturedClass, + platformKey: _captured!, ), + ), child: Text(l10n.pttConfigureSaveAction), ), ], diff --git a/apps/chanora_flutter/lib/src/rust/api.dart b/apps/chanora_flutter/lib/src/rust/api.dart index 80ca2e6..e154883 100644 --- a/apps/chanora_flutter/lib/src/rust/api.dart +++ b/apps/chanora_flutter/lib/src/rust/api.dart @@ -41,6 +41,19 @@ Future disconnect() => RustLib.instance.api.crateApiDisconnect(); /// True if a connection is currently active. Future isConnected() => RustLib.instance.api.crateApiIsConnected(); +/// Handle iOS AVAudioSession route changes (SDD-100). +void handleRouteChange() => RustLib.instance.api.crateApiHandleRouteChange(); + +/// Handle iOS AVAudioSession interruption begin (SDD-101). +void handleInterruptionBegan() => + RustLib.instance.api.crateApiHandleInterruptionBegan(); + +/// Handle iOS AVAudioSession interruption end (SDD-101). +void handleInterruptionEnded({required bool shouldResume}) => RustLib + .instance + .api + .crateApiHandleInterruptionEnded(shouldResume: shouldResume); + /// Set the push-to-talk state. /// /// Superseded in v1 by [`set_transmit_mode`] + the binding capture @@ -427,6 +440,15 @@ sealed class BridgeEvent with _$BridgeEvent { /// Current release-tail in milliseconds (0..=500). required int releaseTailMs, }) = BridgeEvent_VoiceState; + + /// iOS audio interruption state (SDD-101). + const factory BridgeEvent.interruptionState({ + /// True when interruption began, false when it ended. + required bool began, + + /// Resume recommendation from the platform. False on begin. + required bool shouldResume, + }) = BridgeEvent_InterruptionState; } /// Coarse OS-reported network state. Mirrors diff --git a/apps/chanora_flutter/lib/src/rust/api.freezed.dart b/apps/chanora_flutter/lib/src/rust/api.freezed.dart index 6812379..87ab4f2 100644 --- a/apps/chanora_flutter/lib/src/rust/api.freezed.dart +++ b/apps/chanora_flutter/lib/src/rust/api.freezed.dart @@ -55,7 +55,7 @@ extension BridgeEventPatterns on BridgeEvent { /// } /// ``` -@optionalTypeArgs TResult maybeMap({TResult Function( BridgeEvent_Connected value)? connected,TResult Function( BridgeEvent_Lost value)? lost,TResult Function( BridgeEvent_Reconnecting value)? reconnecting,TResult Function( BridgeEvent_Disconnected value)? disconnected,TResult Function( BridgeEvent_AudioStarted value)? audioStarted,TResult Function( BridgeEvent_AudioStopped value)? audioStopped,TResult Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult Function( BridgeEvent_PttCapability value)? pttCapability,TResult Function( BridgeEvent_VoiceState value)? voiceState,required TResult orElse(),}){ +@optionalTypeArgs TResult maybeMap({TResult Function( BridgeEvent_Connected value)? connected,TResult Function( BridgeEvent_Lost value)? lost,TResult Function( BridgeEvent_Reconnecting value)? reconnecting,TResult Function( BridgeEvent_Disconnected value)? disconnected,TResult Function( BridgeEvent_AudioStarted value)? audioStarted,TResult Function( BridgeEvent_AudioStopped value)? audioStopped,TResult Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult Function( BridgeEvent_PttCapability value)? pttCapability,TResult Function( BridgeEvent_VoiceState value)? voiceState,TResult Function( BridgeEvent_InterruptionState value)? interruptionState,required TResult orElse(),}){ final _that = this; switch (_that) { case BridgeEvent_Connected() when connected != null: @@ -67,7 +67,8 @@ return audioStarted(_that);case BridgeEvent_AudioStopped() when audioStopped != return audioStopped(_that);case BridgeEvent_SnapshotChanged() when snapshotChanged != null: return snapshotChanged(_that);case BridgeEvent_PttCapability() when pttCapability != null: return pttCapability(_that);case BridgeEvent_VoiceState() when voiceState != null: -return voiceState(_that);case _: +return voiceState(_that);case BridgeEvent_InterruptionState() when interruptionState != null: +return interruptionState(_that);case _: return orElse(); } @@ -85,7 +86,7 @@ return voiceState(_that);case _: /// } /// ``` -@optionalTypeArgs TResult map({required TResult Function( BridgeEvent_Connected value) connected,required TResult Function( BridgeEvent_Lost value) lost,required TResult Function( BridgeEvent_Reconnecting value) reconnecting,required TResult Function( BridgeEvent_Disconnected value) disconnected,required TResult Function( BridgeEvent_AudioStarted value) audioStarted,required TResult Function( BridgeEvent_AudioStopped value) audioStopped,required TResult Function( BridgeEvent_SnapshotChanged value) snapshotChanged,required TResult Function( BridgeEvent_PttCapability value) pttCapability,required TResult Function( BridgeEvent_VoiceState value) voiceState,}){ +@optionalTypeArgs TResult map({required TResult Function( BridgeEvent_Connected value) connected,required TResult Function( BridgeEvent_Lost value) lost,required TResult Function( BridgeEvent_Reconnecting value) reconnecting,required TResult Function( BridgeEvent_Disconnected value) disconnected,required TResult Function( BridgeEvent_AudioStarted value) audioStarted,required TResult Function( BridgeEvent_AudioStopped value) audioStopped,required TResult Function( BridgeEvent_SnapshotChanged value) snapshotChanged,required TResult Function( BridgeEvent_PttCapability value) pttCapability,required TResult Function( BridgeEvent_VoiceState value) voiceState,required TResult Function( BridgeEvent_InterruptionState value) interruptionState,}){ final _that = this; switch (_that) { case BridgeEvent_Connected(): @@ -97,7 +98,8 @@ return audioStarted(_that);case BridgeEvent_AudioStopped(): return audioStopped(_that);case BridgeEvent_SnapshotChanged(): return snapshotChanged(_that);case BridgeEvent_PttCapability(): return pttCapability(_that);case BridgeEvent_VoiceState(): -return voiceState(_that);} +return voiceState(_that);case BridgeEvent_InterruptionState(): +return interruptionState(_that);} } /// A variant of `map` that fallback to returning `null`. /// @@ -111,7 +113,7 @@ return voiceState(_that);} /// } /// ``` -@optionalTypeArgs TResult? mapOrNull({TResult? Function( BridgeEvent_Connected value)? connected,TResult? Function( BridgeEvent_Lost value)? lost,TResult? Function( BridgeEvent_Reconnecting value)? reconnecting,TResult? Function( BridgeEvent_Disconnected value)? disconnected,TResult? Function( BridgeEvent_AudioStarted value)? audioStarted,TResult? Function( BridgeEvent_AudioStopped value)? audioStopped,TResult? Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult? Function( BridgeEvent_PttCapability value)? pttCapability,TResult? Function( BridgeEvent_VoiceState value)? voiceState,}){ +@optionalTypeArgs TResult? mapOrNull({TResult? Function( BridgeEvent_Connected value)? connected,TResult? Function( BridgeEvent_Lost value)? lost,TResult? Function( BridgeEvent_Reconnecting value)? reconnecting,TResult? Function( BridgeEvent_Disconnected value)? disconnected,TResult? Function( BridgeEvent_AudioStarted value)? audioStarted,TResult? Function( BridgeEvent_AudioStopped value)? audioStopped,TResult? Function( BridgeEvent_SnapshotChanged value)? snapshotChanged,TResult? Function( BridgeEvent_PttCapability value)? pttCapability,TResult? Function( BridgeEvent_VoiceState value)? voiceState,TResult? Function( BridgeEvent_InterruptionState value)? interruptionState,}){ final _that = this; switch (_that) { case BridgeEvent_Connected() when connected != null: @@ -123,7 +125,8 @@ return audioStarted(_that);case BridgeEvent_AudioStopped() when audioStopped != return audioStopped(_that);case BridgeEvent_SnapshotChanged() when snapshotChanged != null: return snapshotChanged(_that);case BridgeEvent_PttCapability() when pttCapability != null: return pttCapability(_that);case BridgeEvent_VoiceState() when voiceState != null: -return voiceState(_that);case _: +return voiceState(_that);case BridgeEvent_InterruptionState() when interruptionState != null: +return interruptionState(_that);case _: return null; } @@ -140,7 +143,7 @@ return voiceState(_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,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)? voiceState,TResult Function( bool began, bool shouldResume)? interruptionState,required TResult orElse(),}) {final _that = this; switch (_that) { case BridgeEvent_Connected() when connected != null: return connected(_that.serverName);case BridgeEvent_Lost() when lost != null: @@ -151,7 +154,8 @@ 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 _: +return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs);case BridgeEvent_InterruptionState() when interruptionState != null: +return interruptionState(_that.began,_that.shouldResume);case _: return orElse(); } @@ -169,7 +173,7 @@ return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTai /// } /// ``` -@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,}) {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) voiceState,required TResult Function( bool began, bool shouldResume) interruptionState,}) {final _that = this; switch (_that) { case BridgeEvent_Connected(): return connected(_that.serverName);case BridgeEvent_Lost(): @@ -180,7 +184,8 @@ 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);} +return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs);case BridgeEvent_InterruptionState(): +return interruptionState(_that.began,_that.shouldResume);} } /// A variant of `when` that fallback to returning `null` /// @@ -194,7 +199,7 @@ return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTai /// } /// ``` -@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,}) {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)? voiceState,TResult? Function( bool began, bool shouldResume)? interruptionState,}) {final _that = this; switch (_that) { case BridgeEvent_Connected() when connected != null: return connected(_that.serverName);case BridgeEvent_Lost() when lost != null: @@ -205,7 +210,8 @@ 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 _: +return voiceState(_that.inChannel,_that.transmitMode,_that.mute,_that.releaseTailMs);case BridgeEvent_InterruptionState() when interruptionState != null: +return interruptionState(_that.began,_that.shouldResume);case _: return null; } @@ -769,6 +775,76 @@ as int, } +} + +/// @nodoc + + +class BridgeEvent_InterruptionState extends BridgeEvent { + const BridgeEvent_InterruptionState({required this.began, required this.shouldResume}): super._(); + + +/// True when interruption began, false when it ended. + final bool began; +/// Resume recommendation from the platform. False on begin. + final bool shouldResume; + +/// Create a copy of BridgeEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$BridgeEvent_InterruptionStateCopyWith get copyWith => _$BridgeEvent_InterruptionStateCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BridgeEvent_InterruptionState&&(identical(other.began, began) || other.began == began)&&(identical(other.shouldResume, shouldResume) || other.shouldResume == shouldResume)); +} + + +@override +int get hashCode => Object.hash(runtimeType,began,shouldResume); + +@override +String toString() { + return 'BridgeEvent.interruptionState(began: $began, shouldResume: $shouldResume)'; +} + + +} + +/// @nodoc +abstract mixin class $BridgeEvent_InterruptionStateCopyWith<$Res> implements $BridgeEventCopyWith<$Res> { + factory $BridgeEvent_InterruptionStateCopyWith(BridgeEvent_InterruptionState value, $Res Function(BridgeEvent_InterruptionState) _then) = _$BridgeEvent_InterruptionStateCopyWithImpl; +@useResult +$Res call({ + bool began, bool shouldResume +}); + + + + +} +/// @nodoc +class _$BridgeEvent_InterruptionStateCopyWithImpl<$Res> + implements $BridgeEvent_InterruptionStateCopyWith<$Res> { + _$BridgeEvent_InterruptionStateCopyWithImpl(this._self, this._then); + + final BridgeEvent_InterruptionState _self; + final $Res Function(BridgeEvent_InterruptionState) _then; + +/// Create a copy of BridgeEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') $Res call({Object? began = null,Object? shouldResume = null,}) { + return _then(BridgeEvent_InterruptionState( +began: null == began ? _self.began : began // ignore: cast_nullable_to_non_nullable +as bool,shouldResume: null == shouldResume ? _self.shouldResume : shouldResume // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + } // dart format on diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.dart index 1fb90e7..d158be7 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.dart @@ -67,7 +67,7 @@ class RustLib extends BaseEntrypoint { String get codegenVersion => '2.12.0'; @override - int get rustContentHash => 1306308591; + int get rustContentHash => 1322894465; static const kDefaultExternalLibraryLoaderConfig = ExternalLibraryLoaderConfig( @@ -105,6 +105,12 @@ abstract class RustLibApi extends BaseApi { Future crateApiGetTransmitMode(); + void crateApiHandleInterruptionBegan(); + + void crateApiHandleInterruptionEnded({required bool shouldResume}); + + void crateApiHandleRouteChange(); + Future crateApiInitStorage({required String dir}); Future crateApiIsConnected(); @@ -469,6 +475,76 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { TaskConstMeta get kCrateApiGetTransmitModeConstMeta => const TaskConstMeta(debugName: "get_transmit_mode", argNames: []); + @override + void crateApiHandleInterruptionBegan() { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 12)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiHandleInterruptionBeganConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiHandleInterruptionBeganConstMeta => + const TaskConstMeta(debugName: "handle_interruption_began", argNames: []); + + @override + void crateApiHandleInterruptionEnded({required bool shouldResume}) { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_bool(shouldResume, serializer); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 13)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiHandleInterruptionEndedConstMeta, + argValues: [shouldResume], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiHandleInterruptionEndedConstMeta => + const TaskConstMeta( + debugName: "handle_interruption_ended", + argNames: ["shouldResume"], + ); + + @override + void crateApiHandleRouteChange() { + return handler.executeSync( + SyncTask( + callFfi: () { + final serializer = SseSerializer(generalizedFrbRustBinding); + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 14)!; + }, + codec: SseCodec( + decodeSuccessData: sse_decode_unit, + decodeErrorData: null, + ), + constMeta: kCrateApiHandleRouteChangeConstMeta, + argValues: [], + apiImpl: this, + ), + ); + } + + TaskConstMeta get kCrateApiHandleRouteChangeConstMeta => + const TaskConstMeta(debugName: "handle_route_change", argNames: []); + @override Future crateApiInitStorage({required String dir}) { return handler.executeNormal( @@ -479,7 +555,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 12, + funcId: 15, port: port_, ); }, @@ -506,7 +582,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 13, + funcId: 16, port: port_, ); }, @@ -533,7 +609,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 14, + funcId: 17, port: port_, ); }, @@ -557,7 +633,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 15)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 18)!; }, codec: SseCodec( decodeSuccessData: sse_decode_String, @@ -587,7 +663,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 16, + funcId: 19, port: port_, ); }, @@ -616,7 +692,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 17, + funcId: 20, port: port_, ); }, @@ -644,7 +720,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 18, + funcId: 21, port: port_, ); }, @@ -672,7 +748,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 19, + funcId: 22, port: port_, ); }, @@ -697,7 +773,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_bridge_network_state(state, serializer); - return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 20)!; + return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 23)!; }, codec: SseCodec( decodeSuccessData: sse_decode_unit, @@ -723,7 +799,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 21, + funcId: 24, port: port_, ); }, @@ -751,7 +827,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 22, + funcId: 25, port: port_, ); }, @@ -779,7 +855,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 23, + funcId: 26, port: port_, ); }, @@ -811,7 +887,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 24, + funcId: 27, port: port_, ); }, @@ -841,7 +917,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 25, + funcId: 28, port: port_, ); }, @@ -869,7 +945,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 26, + funcId: 29, port: port_, ); }, @@ -896,7 +972,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 27, + funcId: 30, port: port_, ); }, @@ -924,7 +1000,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 28, + funcId: 31, port: port_, ); }, @@ -956,7 +1032,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 29, + funcId: 32, port: port_, ); }, @@ -985,7 +1061,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { pdeCallFfi( generalizedFrbRustBinding, serializer, - funcId: 30, + funcId: 33, port: port_, ); }, @@ -1156,6 +1232,11 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { mute: dco_decode_bool(raw[3]), releaseTailMs: dco_decode_u_32(raw[4]), ); + case 9: + return BridgeEvent_InterruptionState( + began: dco_decode_bool(raw[1]), + shouldResume: dco_decode_bool(raw[2]), + ); default: throw Exception("unreachable"); } @@ -1461,6 +1542,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { mute: var_mute, releaseTailMs: var_releaseTailMs, ); + case 9: + var var_began = sse_decode_bool(deserializer); + var var_shouldResume = sse_decode_bool(deserializer); + return BridgeEvent_InterruptionState( + began: var_began, + shouldResume: var_shouldResume, + ); default: throw UnimplementedError(''); } @@ -1792,6 +1880,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_bridge_transmit_mode(transmitMode, serializer); sse_encode_bool(mute, serializer); sse_encode_u_32(releaseTailMs, serializer); + case BridgeEvent_InterruptionState( + began: final began, + shouldResume: final shouldResume, + ): + sse_encode_i_32(9, serializer); + sse_encode_bool(began, serializer); + sse_encode_bool(shouldResume, serializer); } } diff --git a/apps/chanora_flutter/lib/widgets/voice_bar.dart b/apps/chanora_flutter/lib/widgets/voice_bar.dart index 73adabb..8f134f1 100644 --- a/apps/chanora_flutter/lib/widgets/voice_bar.dart +++ b/apps/chanora_flutter/lib/widgets/voice_bar.dart @@ -183,9 +183,7 @@ class VoiceBar extends StatelessWidget { const Spacer(), IconButton( tooltip: l10n.voiceOutputMuteLabel, - icon: Icon( - outputMuted ? Icons.headset_off : Icons.headset, - ), + icon: Icon(outputMuted ? Icons.headset_off : Icons.headset), isSelected: outputMuted, selectedIcon: const Icon(Icons.headset_off), onPressed: onToggleOutputMute, @@ -274,13 +272,11 @@ class VoiceBar extends StatelessWidget { // (single configuration entry point — see the comment // on `onConfigure`). // - // Hidden on touch-only mobile hosts (iOS / iPadOS / - // Android) because the capability story there is always - // "L0Focused via on-screen button" and that's already - // visually obvious from the PTT button being on the - // bar. Showing a degraded-capability badge there would - // be redundant + confusing. - if (isPtt && !_isTouchOnlyPttHost) + // Still shown on touch-only mobile hosts because iOS P0 + // acceptance requires an explicit `L0Focused` badge and + // explanation that global hotkeys are not available in + // the iOS sandbox. + if (isPtt) PttCapabilityBadge( level: pttLevel, backendId: pttBackendId, diff --git a/apps/chanora_flutter/lib/widgets/voice_compact.dart b/apps/chanora_flutter/lib/widgets/voice_compact.dart index 67cd5f0..417c24b 100644 --- a/apps/chanora_flutter/lib/widgets/voice_compact.dart +++ b/apps/chanora_flutter/lib/widgets/voice_compact.dart @@ -256,7 +256,7 @@ class _VoicePttButtonState extends State { /// 3. Release-tail slider (PTT-only). /// 4. Mic level meter. /// 5. TX / RX frame counts. -/// 6. PTT capability badge (desktop only). +/// 6. PTT capability badge. /// /// Mode + release-tail are inlined directly here instead of being /// hidden behind an "Adjust" button → nested dialog. Single-screen @@ -355,8 +355,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { // 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); + final showRoutePicker = !kIsWeb && (Platform.isIOS || Platform.isAndroid); return SafeArea( child: SingleChildScrollView( @@ -365,10 +364,7 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - l10n.voiceSheetTitle, - style: theme.textTheme.titleLarge, - ), + Text(l10n.voiceSheetTitle, style: theme.textTheme.titleLarge), const SizedBox(height: 12), // 1) Audio output route picker tile (mobile only). @@ -428,9 +424,9 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { ], ), Slider( - value: _tail.toDouble().clamp(0, 1000), + value: _tail.toDouble().clamp(0, 500), min: 0, - max: 1000, + max: 500, divisions: 20, label: '$_tail ms', onChanged: _setTail, @@ -470,8 +466,12 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { style: theme.textTheme.bodySmall, ), - // 5) PTT capability badge \u2014 desktop-only. - if (isPtt && !widget.isTouchOnly) ...[ + // 5) PTT capability badge. On iOS this must remain + // visible even though the resolved level is always + // `L0Focused`, because the P0 acceptance flow requires + // honest capability advertising with an explanation of + // the sandbox limitation. + if (isPtt) ...[ const SizedBox(height: 12), PttCapabilityBadge( level: widget.pttLevel, @@ -506,8 +506,8 @@ class _ModeRow extends StatelessWidget { final color = disabled ? theme.colorScheme.onSurfaceVariant.withAlpha(120) : selected - ? theme.colorScheme.primary - : theme.colorScheme.onSurface; + ? theme.colorScheme.primary + : theme.colorScheme.onSurface; return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(8), @@ -604,8 +604,11 @@ class _AudioOutputTileState extends State<_AudioOutputTile> { } } - static String _portLabel(AVAudioSessionPort? type, String fallback, - AppL10n l10n) { + static String _portLabel( + AVAudioSessionPort? type, + String fallback, + AppL10n l10n, + ) { switch (type) { case AVAudioSessionPort.builtInSpeaker: return l10n.audioRouteSpeaker; @@ -783,8 +786,9 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> { // mic by default, or BT/wired if connected and selected // elsewhere). For consistency, only switch input when the // user explicitly picks a non-speaker input row. - await AVAudioSession() - .overrideOutputAudioPort(AVAudioSessionPortOverride.speaker); + await AVAudioSession().overrideOutputAudioPort( + AVAudioSessionPortOverride.speaker, + ); debugPrint('chanora: audio output -> speakerphone (override applied)'); } catch (e, st) { debugPrint('chanora: _selectSpeaker FAILED: $e\n$st'); @@ -800,8 +804,9 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> { // which is the built-in receiver. Calling setPreferredInput // explicitly here is redundant and risks the same recalc // race that broke _selectSpeaker before. - await AVAudioSession() - .overrideOutputAudioPort(AVAudioSessionPortOverride.none); + await AVAudioSession().overrideOutputAudioPort( + AVAudioSessionPortOverride.none, + ); debugPrint('chanora: audio output -> receiver (override cleared)'); } catch (e, st) { debugPrint('chanora: _selectReceiver FAILED: $e\n$st'); @@ -817,11 +822,13 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> { // OWN output (the user hears audio through the same device // they speak into), so .none + setPreferredInput is the // correct combo here. - await AVAudioSession() - .overrideOutputAudioPort(AVAudioSessionPortOverride.none); + await AVAudioSession().overrideOutputAudioPort( + AVAudioSessionPortOverride.none, + ); await AVAudioSession().setPreferredInput(port); debugPrint( - 'chanora: audio output -> ${port.portName} (${port.portType})'); + 'chanora: audio output -> ${port.portName} (${port.portType})', + ); } catch (e, st) { debugPrint('chanora: _selectInput FAILED: $e\n$st'); } @@ -843,10 +850,12 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> { ); } - final currentOutputType = - _route?.outputs.isNotEmpty == true ? _route!.outputs.first.portType : null; - final currentInputUid = - _route?.inputs.isNotEmpty == true ? _route!.inputs.first.uid : null; + final currentOutputType = _route?.outputs.isNotEmpty == true + ? _route!.outputs.first.portType + : null; + final currentInputUid = _route?.inputs.isNotEmpty == true + ? _route!.inputs.first.uid + : null; // Whether the active route is the speakerphone override (built-in // speaker is the output but the actual session category isn't @@ -868,10 +877,7 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - l10n.audioOutputLabel, - style: theme.textTheme.titleLarge, - ), + Text(l10n.audioOutputLabel, style: theme.textTheme.titleLarge), const SizedBox(height: 16), _PickerRow( icon: Icons.volume_up, @@ -889,7 +895,10 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> { _PickerRow( icon: _AudioOutputTileState._portIcon(port.portType), label: _AudioOutputTileState._portLabel( - port.portType, port.portName, l10n), + port.portType, + port.portName, + l10n, + ), selected: !isSpeaker && port.uid == currentInputUid, onTap: () => _selectInput(port), ), @@ -916,8 +925,9 @@ class _PickerRow extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final color = - selected ? theme.colorScheme.primary : theme.colorScheme.onSurface; + final color = selected + ? theme.colorScheme.primary + : theme.colorScheme.onSurface; return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(8), @@ -933,8 +943,7 @@ class _PickerRow extends StatelessWidget { style: theme.textTheme.bodyLarge?.copyWith(color: color), ), ), - if (selected) - Icon(Icons.check, color: theme.colorScheme.primary), + if (selected) Icon(Icons.check, color: theme.colorScheme.primary), ], ), ), diff --git a/apps/chanora_flutter/macos/Runner.xcodeproj/project.pbxproj b/apps/chanora_flutter/macos/Runner.xcodeproj/project.pbxproj index 07e62ca..20b00ed 100644 --- a/apps/chanora_flutter/macos/Runner.xcodeproj/project.pbxproj +++ b/apps/chanora_flutter/macos/Runner.xcodeproj/project.pbxproj @@ -63,7 +63,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 06E1AA7E1FB968C1D78DA8DE /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 06E1AA7E1FB968C1D78DA8DE /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; @@ -393,10 +393,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -578,11 +582,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = 349G7M4TQQ; ENABLE_APP_SANDBOX = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; @@ -728,11 +733,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = 349G7M4TQQ; ENABLE_APP_SANDBOX = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = NO; ENABLE_OUTGOING_NETWORK_CONNECTIONS = NO; @@ -763,11 +769,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = ""; + "DEVELOPMENT_TEAM[sdk=macosx*]" = 349G7M4TQQ; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index 438fbc3..c1db260 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -48,8 +48,8 @@ use tracing::{info, warn}; pub mod ptt; pub use chanora_audio::{ - AudioEngine, AudioEngineConfig, AudioTransmitGate, PttBackendDescriptor, - PttCapabilityLevel, ReleaseTailTimer, TransmitMode, TransmitModeSelector, + AudioEngine, AudioEngineConfig, AudioTransmitGate, PttBackendDescriptor, PttCapabilityLevel, + ReleaseTailTimer, TransmitMode, TransmitModeSelector, }; pub use chanora_audio::{PttBinding, PttInputClass}; pub use chanora_diagnostics::{ @@ -172,6 +172,15 @@ pub enum SessionEvent { /// Current release-tail in milliseconds (0..=500). release_tail_ms: u32, }, + /// iOS audio-session interruption state (SDD-101). Emitted when + /// interruption begins and when it ends (with the platform hint + /// indicating whether audio should resume). + InterruptionState { + /// True when interruption began, false when interruption ended. + began: bool, + /// Platform-provided resume hint. For begin events this is false. + should_resume: bool, + }, } /// Coarse OS-reported network state. Populated by the Flutter side @@ -897,6 +906,53 @@ impl ChanoraSession { Ok((audio.frames_sent(), audio.frames_received(), audio.ptt())) } + /// iOS route-change hook (SDD-100). No-op when audio is not + /// running. + pub async fn ios_handle_route_change(&self) -> Result<(), CoreError> { + let guard = self.inner.lock().await; + if let Some(state) = guard.as_ref() { + if let Some(audio) = state.audio.as_ref() { + audio.ios_restart_voice_unit()?; + } + } + Ok(()) + } + + /// iOS interruption-began hook (SDD-101). No-op when audio is + /// not running. + pub async fn ios_handle_interruption_began(&self) -> Result<(), CoreError> { + let guard = self.inner.lock().await; + if let Some(state) = guard.as_ref() { + if let Some(audio) = state.audio.as_ref() { + audio.ios_pause_voice_unit()?; + } + } + let _ = self.events_tx.send(SessionEvent::InterruptionState { + began: true, + should_resume: false, + }); + Ok(()) + } + + /// iOS interruption-ended hook (SDD-101). Resumes only when + /// `should_resume` is true. + pub async fn ios_handle_interruption_ended(&self, should_resume: bool) -> Result<(), CoreError> { + let _ = self.events_tx.send(SessionEvent::InterruptionState { + began: false, + should_resume, + }); + if !should_resume { + return Ok(()); + } + let guard = self.inner.lock().await; + if let Some(state) = guard.as_ref() { + if let Some(audio) = state.audio.as_ref() { + audio.ios_resume_voice_unit()?; + } + } + Ok(()) + } + // ---------- v1 audio + PTT lifecycle (SDD-094/095/096) ---------- /// Idempotent helper that ensures the audio engine is running @@ -953,11 +1009,33 @@ impl ChanoraSession { // `ProtocolError::ServerRejected` and don't need the // snapshot polling at all. if let Err(e) = self.move_to_channel(channel_id, password).await { - // Roll the selector back so the UI doesn't display a - // fake "joined" state. - self.voice_selector.set_in_channel(false); - self.emit_voice_state(false).await; - return Err(e); + // TS3 error 0x0302 = `channel_already_in`: we're already + // in the target channel, so this is a no-op success. + // Rolling `in_channel` back to false would break PTT + // because the transmit-mode selector clamps + // `transmit_active` to false when `in_channel` is false. + // (SDD-094, SAD-081, SRS-204) + if matches!( + &e, + CoreError::Protocol(chanora_protocol::ProtocolError::ServerRejected { + code: 0x0302, + .. + }) + ) { + info!( + target: "chanora_core", + channel_id, + "voice_join: already in channel (0x0302); treating as success" + ); + // Fall through to audio-start + snapshot confirmation below. + } else { + // Genuine move failures (wrong password, no + // permission, channel full, etc.) must roll back + // local in-channel state. + self.voice_selector.set_in_channel(false); + self.emit_voice_state(false).await; + return Err(e); + } } // 2. Bring the audio engine up. Tolerate failure: the // server-side channel move has ALREADY succeeded (step @@ -1022,9 +1100,8 @@ impl ChanoraSession { // Use a sentinel "unknown" code (the canonical // TS3 error catalogue uses 0x0001 for `undefined`). code: 0x0001, - message: - "channel move did not take effect server-side within timeout" - .to_string(), + message: "channel move did not take effect server-side within timeout" + .to_string(), }, )); } @@ -1036,10 +1113,7 @@ impl ChanoraSession { /// Find our own client in a snapshot and return `(client_id, /// channel_id)`. Used by `voice_join` to confirm the server /// actually applied a channel move. - async fn find_own_in( - &self, - snap: &chanora_protocol::ServerSnapshot, - ) -> Option<(u64, u64)> { + async fn find_own_in(&self, snap: &chanora_protocol::ServerSnapshot) -> Option<(u64, u64)> { let own_id = snap.own_client_id; let me = snap.clients.iter().find(|c| c.id.0 == own_id)?; Some((me.id.0, me.channel.0)) @@ -1403,7 +1477,11 @@ async fn supervisor_loop( // sleep and resets the attempt counter so the // next outage starts with the smallest backoff // window again. - enum SleepOutcome { Elapsed, NetworkUp, Cancelled } + enum SleepOutcome { + Elapsed, + NetworkUp, + Cancelled, + } let outcome = tokio::select! { biased; _ = &mut cancel_rx => SleepOutcome::Cancelled, @@ -1495,9 +1573,14 @@ async fn supervisor_loop( if let Some(state) = guard.as_mut() { let voice_out = state.protocol.voice_out(); if let Some(voice_in) = state.protocol.take_voice_in() { - let gate = chanora_audio::AudioTransmitGate::new(audio_cfg.ptt_initial); + let gate = chanora_audio::AudioTransmitGate::new( + audio_cfg.ptt_initial, + ); match chanora_audio::AudioEngine::start_with_gate( - audio_cfg, voice_out, voice_in, gate.clone(), + audio_cfg, + voice_out, + voice_in, + gate.clone(), ) { Ok(engine) => { state.audio = Some(engine); @@ -1528,21 +1611,19 @@ async fn supervisor_loop( ); } } - let _ = events_tx - .send(SessionEvent::AudioStarted); + let _ = events_tx.send(SessionEvent::AudioStarted); // Re-publish the post-reconnect // capability (SRS-196 / SDD-091). let d = controller.descriptor().await; - let _ = events_tx.send( - SessionEvent::PttCapability { + let _ = + events_tx.send(SessionEvent::PttCapability { level: d.level.as_str().to_string(), backend_id: d.backend_id.to_string(), bound_input_class: d .bound_input_class .unwrap_or("") .to_string(), - }, - ); + }); } Err(e) => { warn!( @@ -1660,10 +1741,7 @@ mod tests { let mut b = a.clone(); // User moves from channel 1 → 2. Counts unchanged. b.clients[0].channel = chanora_protocol::ChannelId(2); - assert_ne!( - super::snapshot_signature(&a), - super::snapshot_signature(&b) - ); + assert_ne!(super::snapshot_signature(&a), super::snapshot_signature(&b)); } #[test] @@ -1693,17 +1771,17 @@ mod tests { }; let mut b = a.clone(); b.channels.reverse(); - assert_eq!( - super::snapshot_signature(&a), - super::snapshot_signature(&b) - ); + assert_eq!(super::snapshot_signature(&a), super::snapshot_signature(&b)); } #[tokio::test] async fn empty_address_is_rejected() { let s = ChanoraSession::new(); let r = s.connect(ConnectConfig::default()).await; - assert!(matches!(r, Err(CoreError::Protocol(ProtocolError::Invalid(_))))); + assert!(matches!( + r, + Err(CoreError::Protocol(ProtocolError::Invalid(_))) + )); } #[tokio::test] diff --git a/core/chanora_core/src/ptt.rs b/core/chanora_core/src/ptt.rs index bdf31b3..536f5a6 100644 --- a/core/chanora_core/src/ptt.rs +++ b/core/chanora_core/src/ptt.rs @@ -101,8 +101,14 @@ impl PttController { /// `release_tail.key_down/key_up` so the 200 ms tail spec'd in /// SDD-096 actually fires between key release and gate close. pub fn new(release_tail: Arc) -> Arc { + Self::new_with_backend(release_tail, select_ptt_backend()) + } + + fn new_with_backend( + release_tail: Arc, + mut backend: Box, + ) -> Arc { let press_gate = AudioTransmitGate::new(false); - let mut backend = select_ptt_backend(); let initial_binding = PttBinding::none(); match backend.start(press_gate.clone(), initial_binding.clone()) { Ok(()) => { @@ -178,6 +184,14 @@ impl PttController { }) } + #[cfg(test)] + fn new_for_test(release_tail: Arc) -> Arc { + Self::new_with_backend( + release_tail, + Box::new(chanora_audio::ptt_backends::FocusedPttBackend::new()), + ) + } + /// Replace the active binding (SDD-088 public surface). /// /// Returns the freshly-published descriptor so callers can @@ -189,9 +203,7 @@ impl PttController { binding: PttBinding, ) -> Result { let mut backend_guard = self.backend.lock().await; - let backend = backend_guard - .as_mut() - .ok_or(PttControllerError::NotArmed)?; + let backend = backend_guard.as_mut().ok_or(PttControllerError::NotArmed)?; backend.rebind(binding.clone())?; let descriptor = backend.descriptor(); // Record the new binding under its own mutex so the @@ -298,7 +310,13 @@ mod tests { use chanora_audio::{TransmitMode, TransmitModeSelector}; use std::time::Duration; - fn setup(tail_ms: u32) -> (AudioTransmitGate, Arc, Arc) { + fn setup( + tail_ms: u32, + ) -> ( + AudioTransmitGate, + Arc, + Arc, + ) { let gate = AudioTransmitGate::new(false); let selector = Arc::new(TransmitModeSelector::new(gate.clone())); selector.set_mode(TransmitMode::Ptt); @@ -310,7 +328,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn controller_arms_and_reports_capability() { let (_gate, _sel, tail) = setup(0); - let controller = PttController::new(tail); + let controller = PttController::new_for_test(tail); let desc = controller.descriptor().await; assert!(!desc.backend_id.is_empty()); let level = controller.current_capability(); @@ -322,7 +340,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn set_binding_updates_descriptor_watch() { let (_gate, _sel, tail) = setup(0); - let controller = PttController::new(tail); + let controller = PttController::new_for_test(tail); let mut desc_rx = controller.descriptor_watch(); let _ = desc_rx.borrow_and_update(); let binding = PttBinding { @@ -330,7 +348,10 @@ mod tests { platform_key: "Space".to_string(), }; let new_desc = controller.set_binding(binding.clone()).await.unwrap(); - assert_eq!(new_desc.backend_id, controller.descriptor().await.backend_id); + assert_eq!( + new_desc.backend_id, + controller.descriptor().await.backend_id + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -339,7 +360,7 @@ mod tests { // a backend's raw key-down), expect the real transmit gate // to follow with the configured tail on release. let (real_gate, _sel, tail) = setup(80); - let controller = PttController::new(tail); + let controller = PttController::new_for_test(tail); // Give the edge-watcher task a tick to subscribe. tokio::time::sleep(Duration::from_millis(15)).await; // Simulate backend key-down. @@ -361,7 +382,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn stop_clears_press_and_cancels_tail() { let (real_gate, _sel, tail) = setup(200); - let controller = PttController::new(tail); + let controller = PttController::new_for_test(tail); tokio::time::sleep(Duration::from_millis(15)).await; controller.press_gate().set(true); tokio::time::sleep(Duration::from_millis(15)).await; @@ -383,7 +404,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn press_on_release_off_zero_tail() { let (real_gate, _sel, tail) = setup(0); - let controller = PttController::new(tail); + let controller = PttController::new_for_test(tail); tokio::time::sleep(Duration::from_millis(10)).await; assert!(!real_gate.load(), "idle baseline must be off"); @@ -403,7 +424,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn press_on_release_off_default_tail() { let (real_gate, _sel, tail) = setup(200); - let controller = PttController::new(tail); + let controller = PttController::new_for_test(tail); tokio::time::sleep(Duration::from_millis(10)).await; controller.press_gate().set(true); @@ -413,7 +434,10 @@ mod tests { controller.press_gate().set(false); // Mid-tail: still on. tokio::time::sleep(Duration::from_millis(50)).await; - assert!(real_gate.load(), "tail window: pttActive must still be true"); + assert!( + real_gate.load(), + "tail window: pttActive must still be true" + ); // Past the tail: off. tokio::time::sleep(Duration::from_millis(220)).await; @@ -456,7 +480,7 @@ mod windows_full_chain_tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn full_chain_press_release_zero_tail() { let (real_gate, _sel, tail) = setup(0); - let controller = PttController::new(tail); + let controller = PttController::new_for_test(tail); tokio::time::sleep(Duration::from_millis(10)).await; assert!(!real_gate.load()); @@ -473,7 +497,7 @@ mod windows_full_chain_tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn full_chain_press_release_default_tail() { let (real_gate, _sel, tail) = setup(200); - let controller = PttController::new(tail); + let controller = PttController::new_for_test(tail); tokio::time::sleep(Duration::from_millis(10)).await; controller.press_gate().set(true); @@ -494,7 +518,7 @@ mod windows_full_chain_tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mid_press_rebind_abandons_in_flight_press() { let (real_gate, _sel, tail) = setup(0); - let controller = PttController::new(tail); + let controller = PttController::new_for_test(tail); tokio::time::sleep(Duration::from_millis(10)).await; // Press. diff --git a/core/chanora_core/tests/mvp_storage.rs b/core/chanora_core/tests/mvp_storage.rs index aa2790b..f70dc76 100644 --- a/core/chanora_core/tests/mvp_storage.rs +++ b/core/chanora_core/tests/mvp_storage.rs @@ -22,7 +22,10 @@ async fn init_storage_encrypts_identity_and_bookmark_passwords() { // No identity yet. let id_path = tmp.join("identity.tskey"); - assert!(!id_path.exists(), "identity should not exist before first connect"); + assert!( + !id_path.exists(), + "identity should not exist before first connect" + ); // A bookmark with a password lands as an encrypted blob. let id = session diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs index aef1028..c506b86 100644 --- a/crates/chanora_audio/src/engine.rs +++ b/crates/chanora_audio/src/engine.rs @@ -216,12 +216,7 @@ impl AudioEngine { // other platform stays on the cpal / SDL flow below. #[cfg(target_os = "ios")] { - return Self::start_with_gate_ios( - cfg, - voice_out_tx, - voice_in_rx, - transmit_gate, - ); + return Self::start_with_gate_ios(cfg, voice_out_tx, voice_in_rx, transmit_gate); } #[cfg(not(target_os = "ios"))] { @@ -684,6 +679,55 @@ impl AudioEngine { info!(target: "chanora_audio", "audio engine stopped"); } + /// iOS-only: restart the underlying VoiceProcessingIO unit after + /// route changes. + pub fn ios_restart_voice_unit(&self) -> Result<(), AudioError> { + #[cfg(target_os = "ios")] + { + let mut guard = self._ios_voice_unit.lock().unwrap(); + let unit = guard + .as_mut() + .ok_or_else(|| AudioError::Backend("ios voice unit not running".to_string()))?; + return unit.restart(); + } + #[cfg(not(target_os = "ios"))] + { + Ok(()) + } + } + + /// iOS-only: pause the underlying VoiceProcessingIO unit. + pub fn ios_pause_voice_unit(&self) -> Result<(), AudioError> { + #[cfg(target_os = "ios")] + { + let mut guard = self._ios_voice_unit.lock().unwrap(); + let unit = guard + .as_mut() + .ok_or_else(|| AudioError::Backend("ios voice unit not running".to_string()))?; + return unit.pause(); + } + #[cfg(not(target_os = "ios"))] + { + Ok(()) + } + } + + /// iOS-only: resume the underlying VoiceProcessingIO unit. + pub fn ios_resume_voice_unit(&self) -> Result<(), AudioError> { + #[cfg(target_os = "ios")] + { + let mut guard = self._ios_voice_unit.lock().unwrap(); + let unit = guard + .as_mut() + .ok_or_else(|| AudioError::Backend("ios voice unit not running".to_string()))?; + return unit.resume(); + } + #[cfg(not(target_os = "ios"))] + { + Ok(()) + } + } + /// Set the **transmission gate** (SRS-201). When true the /// encoder feed is allowed to emit Opus frames; when false the /// captured audio is discarded before encoding. This is the @@ -768,8 +812,7 @@ impl AudioEngine { /// sensible range internally. pub fn set_output_gain(&self, gain: f32) { let clamped = gain.clamp(0.0, 4.0); - self.output_gain - .store(clamped.to_bits(), Ordering::Relaxed); + self.output_gain.store(clamped.to_bits(), Ordering::Relaxed); } /// Current master output gain. @@ -818,12 +861,8 @@ fn try_open_capture( in_stream_cfg.buffer_size = cpal::BufferSize::Default; } - let mut opus_enc = OpusEncoder::new( - OpusSampleRate::Hz48000, - OpusChannels::Mono, - OpusApp::Voip, - ) - .map_err(|e| AudioError::Opus(format!("encoder new: {e}")))?; + let mut opus_enc = OpusEncoder::new(OpusSampleRate::Hz48000, OpusChannels::Mono, OpusApp::Voip) + .map_err(|e| AudioError::Opus(format!("encoder new: {e}")))?; // Opus VOIP tuning. Defaults give us 'auto' bitrate (can drop // to ~6 kbps during silence \u2014 which sounds garbled when @@ -1147,13 +1186,12 @@ where // `same_rate` is the common case where the device is already at // 48 kHz — bypass the resampler entirely. let same_rate = dev_sample_rate == SAMPLE_RATE; - let resample_state: Arc> = Arc::new(Mutex::new( - PlaybackResampleState { + let resample_state: Arc> = + Arc::new(Mutex::new(PlaybackResampleState { pos: 0.0, last_l: 0.0, last_r: 0.0, - }, - )); + })); // Reusable scratch buffer for 48 kHz stereo samples coming out // of AudioHandler. Allocating a fresh `Vec` per cpal callback on // glibc malloc was costing measurable time on the realtime audio diff --git a/crates/chanora_audio/src/ios_voice_unit.rs b/crates/chanora_audio/src/ios_voice_unit.rs index ab90b24..1f04645 100644 --- a/crates/chanora_audio/src/ios_voice_unit.rs +++ b/crates/chanora_audio/src/ios_voice_unit.rs @@ -85,8 +85,8 @@ use audiopus::{ }; use coreaudio::audio_unit::audio_format::LinearPcmFlags; use coreaudio::audio_unit::render_callback::{self, data}; -use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat}; use coreaudio::audio_unit::IOType; +use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat}; use tokio::sync::mpsc; use tracing::{debug, error, info, warn}; use tsclientlib::audio::AudioHandler; @@ -110,7 +110,6 @@ const FRAME_SAMPLES_MONO: usize = 960; /// shared `crate::framing` module. const MAX_OPUS_FRAME: usize = 1275; - /// Sample rate every layer above us assumes. Matches the Opus /// encoder rate, the `tsclientlib::AudioHandler` mix rate, and the /// sample rate we ask iOS to give us via VPIO's StreamFormat. @@ -175,12 +174,9 @@ impl IosCaptureState { 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 (ios): {e}")))?; + let mut encoder = + OpusEncoder::new(OpusSampleRate::Hz48000, OpusChannels::Mono, OpusApp::Voip) + .map_err(|e| AudioError::Opus(format!("encoder new (ios): {e}")))?; // VoIP-tuned settings — bitrate 32 kbps, complexity 10, // inband FEC on, packet-loss-perc 5. Soft-fail each setter @@ -438,12 +434,8 @@ impl IosVoiceUnit { // scratch are owned by the closure — no Mutex needed // because the input callback is the sole writer/reader on // the audio thread. - let mut capture_state = IosCaptureState::new( - voice_out_tx, - transmit_active, - frames_sent, - mic_gain, - )?; + let mut capture_state = + IosCaptureState::new(voice_out_tx, transmit_active, frames_sent, mic_gain)?; unit.set_input_callback(move |args: render_callback::Args>| { // VPIO with our pinned stream format delivers @@ -530,10 +522,21 @@ impl IosVoiceUnit { // earlier callbacks (when scratch was bigger) would // leak through otherwise. scratch_stereo[..needed].fill(0.0); - // Lock + fill. Same pattern as Linux/SDL output. - { - let mut h = handler_for_render.lock().unwrap(); - let _removed = h.fill_buffer(&mut scratch_stereo[..needed]); + // Non-blocking fill on the realtime callback thread. + // If the inbound forwarder currently owns this mutex, + // emit this period as silence instead of blocking and + // risking an AudioUnit underrun pop/click. + match handler_for_render.try_lock() { + Ok(mut h) => { + let _removed = h.fill_buffer(&mut scratch_stereo[..needed]); + } + Err(std::sync::TryLockError::WouldBlock) => { + // scratch_stereo is already zeroed above. + } + Err(std::sync::TryLockError::Poisoned(e)) => { + // Never panic on the realtime IO thread. + warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {e}"); + } } // Downmix stereo f32 -> mono i16 with master gain. @@ -652,9 +655,43 @@ impl IosVoiceUnit { ), } - Ok(Self { - unit, - }) + Ok(Self { unit }) + } + + /// Restart the audio unit after route change handling. + /// + /// Route rebinding on iOS is most reliable when we bounce the + /// VoiceProcessingIO unit through an uninitialize/reinitialize + /// cycle, then start again. + pub fn restart(&mut self) -> Result<(), AudioError> { + self.unit + .stop() + .map_err(|e| AudioError::Backend(format!("vpio restart stop: {e}")))?; + self.unit + .uninitialize() + .map_err(|e| AudioError::Backend(format!("vpio restart uninit: {e}")))?; + self.unit + .initialize() + .map_err(|e| AudioError::Backend(format!("vpio restart init: {e}")))?; + self.unit + .start() + .map_err(|e| AudioError::Backend(format!("vpio restart start: {e}")))?; + info!(target: "chanora_audio", "ios VPIO audio unit restarted"); + Ok(()) + } + + /// Pause the audio unit during an interruption. + pub fn pause(&mut self) -> Result<(), AudioError> { + self.unit + .stop() + .map_err(|e| AudioError::Backend(format!("vpio pause stop: {e}"))) + } + + /// Resume the audio unit after an interruption. + pub fn resume(&mut self) -> Result<(), AudioError> { + self.unit + .start() + .map_err(|e| AudioError::Backend(format!("vpio resume start: {e}"))) } } diff --git a/crates/chanora_audio/src/lib.rs b/crates/chanora_audio/src/lib.rs index a42876e..cc6ca9d 100644 --- a/crates/chanora_audio/src/lib.rs +++ b/crates/chanora_audio/src/lib.rs @@ -42,9 +42,7 @@ mod sdl_output; mod ios_voice_unit; pub use engine::{AudioEngine, AudioEngineConfig}; -pub use ptt::{ - AudioTransmitGate, MissedKeyUpWatchdog, PttBackendDescriptor, PttCapabilityLevel, -}; +pub use ptt::{AudioTransmitGate, MissedKeyUpWatchdog, PttBackendDescriptor, PttCapabilityLevel}; pub use ptt_backends::{ select as select_ptt_backend, DesktopPttBackend, FocusedPttBackend, PttBackendError, PttBinding, PttInputClass, diff --git a/crates/chanora_audio/src/ptt.rs b/crates/chanora_audio/src/ptt.rs index 58793e2..a6b7a82 100644 --- a/crates/chanora_audio/src/ptt.rs +++ b/crates/chanora_audio/src/ptt.rs @@ -421,7 +421,10 @@ mod tests { // should re-arm the watchdog cleanly. g.set(true); tokio::time::sleep(Duration::from_millis(200)).await; - assert!(!g.load(), "watchdog should fire on the second press as well"); + assert!( + !g.load(), + "watchdog should fire on the second press as well" + ); } /// Continuous-mode regression: when the watchdog subscribes to diff --git a/crates/chanora_audio/src/ptt_backends/linux.rs b/crates/chanora_audio/src/ptt_backends/linux.rs index a0ac894..f2580e6 100644 --- a/crates/chanora_audio/src/ptt_backends/linux.rs +++ b/crates/chanora_audio/src/ptt_backends/linux.rs @@ -46,9 +46,7 @@ use zbus::blocking::Connection as BlockingConnection; use zbus::zvariant::{OwnedValue, Value}; use zbus::{proxy, Connection as AsyncConnection}; -use super::{ - AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding, PttInputClass, -}; +use super::{AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding, PttInputClass}; use crate::ptt::{PttBackendDescriptor, PttCapabilityLevel}; /// Try to construct a `LinuxGnomeWaylandBackend`. Returns `None` @@ -84,7 +82,9 @@ fn is_gnome_on_wayland() -> bool { let desktop = env::var("XDG_CURRENT_DESKTOP") .unwrap_or_default() .to_ascii_lowercase(); - desktop.split(':').any(|s| s == "gnome" || s == "gnome-flashback") + desktop + .split(':') + .any(|s| s == "gnome" || s == "gnome-flashback") } // ---------- D-Bus proxies ---------- @@ -171,11 +171,7 @@ trait Request { /// is `0` for success, `1` for user cancellation, `2` for /// other failure. #[zbus(signal)] - fn response( - &self, - response: u32, - results: HashMap, - ) -> zbus::Result<()>; + fn response(&self, response: u32, results: HashMap) -> zbus::Result<()>; /// Cancel an in-flight request. fn close(&self) -> zbus::Result<()>; @@ -246,13 +242,11 @@ impl LinuxGnomeWaylandBackend { let join_result = std::thread::Builder::new() .name("chanora-ptt-portal-probe".to_string()) .spawn(|| -> Result { - let conn = BlockingConnection::session() - .map_err(|e| format!("session bus: {e}"))?; - let proxy = BlockingGlobalShortcutsProxy::new(&conn) - .map_err(|e| format!("proxy: {e}"))?; - proxy - .version() - .map_err(|e| format!("portal version: {e}")) + let conn = + BlockingConnection::session().map_err(|e| format!("session bus: {e}"))?; + let proxy = + BlockingGlobalShortcutsProxy::new(&conn).map_err(|e| format!("proxy: {e}"))?; + proxy.version().map_err(|e| format!("portal version: {e}")) }) .map_err(|e| PttBackendError::Init(format!("probe thread spawn: {e}")))? .join() @@ -309,9 +303,10 @@ impl DesktopPttBackend for LinuxGnomeWaylandBackend { // Stash command + worker handles. `try_lock` is fine: the // backend isn't yet shared, and `start` is called once at // engine init. - let mut inner = self.inner.try_lock().map_err(|_| { - PttBackendError::Init("backend inner mutex contended".to_string()) - })?; + let mut inner = self + .inner + .try_lock() + .map_err(|_| PttBackendError::Init("backend inner mutex contended".to_string()))?; // Clean up any prior worker (defensive — `start` is // expected to be called exactly once per backend // instance). @@ -546,7 +541,9 @@ async fn create_session( .get("session_handle") .and_then(|v| <&str>::try_from(v).ok()) .map(|s| s.to_string()) - .ok_or_else(|| zbus::Error::Failure("CreateSession returned no session_handle".to_string()))?; + .ok_or_else(|| { + zbus::Error::Failure("CreateSession returned no session_handle".to_string()) + })?; Ok(zbus::zvariant::OwnedObjectPath::try_from(session_handle) .map_err(|e| zbus::Error::Failure(format!("session_handle path parse: {e}")))?) } @@ -714,19 +711,13 @@ mod tests { #[test] fn classify_returns_keyboard_for_typical_trigger_description() { let v = shortcuts_owned_value(vec![shortcut_entry(SHORTCUT_ID, Some("Ctrl+Alt+P"))]); - assert_eq!( - classify_shortcuts_value(&v), - Some(PttInputClass::Keyboard) - ); + assert_eq!(classify_shortcuts_value(&v), Some(PttInputClass::Keyboard)); } #[test] fn classify_returns_keyboard_when_trigger_description_missing() { let v = shortcuts_owned_value(vec![shortcut_entry(SHORTCUT_ID, None)]); - assert_eq!( - classify_shortcuts_value(&v), - Some(PttInputClass::Keyboard) - ); + assert_eq!(classify_shortcuts_value(&v), Some(PttInputClass::Keyboard)); } #[test] diff --git a/crates/chanora_audio/src/ptt_backends/macos.rs b/crates/chanora_audio/src/ptt_backends/macos.rs index abc4a96..1609060 100644 --- a/crates/chanora_audio/src/ptt_backends/macos.rs +++ b/crates/chanora_audio/src/ptt_backends/macos.rs @@ -38,9 +38,7 @@ use std::time::Duration; use tokio::sync::watch; use tracing::{info, warn}; -use super::{ - AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding, PttInputClass, -}; +use super::{AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding, PttInputClass}; use crate::ptt::{PttBackendDescriptor, PttCapabilityLevel}; // ---------- FFI ---------- @@ -407,15 +405,10 @@ impl MacOSEventTapBackend { /// Build the descriptor for a given permission + binding pair /// (used by `descriptor()` and the re-query worker). - fn build_descriptor( - permission: PermissionState, - class: PttInputClass, - ) -> PttBackendDescriptor { + fn build_descriptor(permission: PermissionState, class: PttInputClass) -> PttBackendDescriptor { let level = match permission { PermissionState::Granted => match class { - PttInputClass::MouseSideButton => { - PttCapabilityLevel::L3GlobalWithMouseButtons - } + PttInputClass::MouseSideButton => PttCapabilityLevel::L3GlobalWithMouseButtons, _ => PttCapabilityLevel::L2GlobalHoldToTalk, }, // Undetermined or Denied (we wouldn't be here for @@ -535,9 +528,8 @@ impl DesktopPttBackend for MacOSEventTapBackend { } return; } - let source = unsafe { - CFMachPortCreateRunLoopSource(std::ptr::null_mut(), port, 0) - }; + let source = + unsafe { CFMachPortCreateRunLoopSource(std::ptr::null_mut(), port, 0) }; if source.is_null() { warn!( target: "chanora_audio", @@ -613,10 +605,7 @@ impl DesktopPttBackend for MacOSEventTapBackend { let now = query_permission(); if now != last { perm_atomic.store(now.to_u8(), Ordering::Relaxed); - let desc = MacOSEventTapBackend::build_descriptor( - now, - perm_binding_class, - ); + let desc = MacOSEventTapBackend::build_descriptor(now, perm_binding_class); let _ = perm_desc_tx.send(desc); info!( target: "chanora_audio", @@ -711,9 +700,7 @@ extern "C" fn tap_callback( // restart the app. The CGEvent docs explicitly say returning // the event unchanged is the correct no-op for these // notification types. - if etype == KCG_EVENT_TAP_DISABLED_BY_TIMEOUT - || etype == KCG_EVENT_TAP_DISABLED_BY_USER_INPUT - { + if etype == KCG_EVENT_TAP_DISABLED_BY_TIMEOUT || etype == KCG_EVENT_TAP_DISABLED_BY_USER_INPUT { warn!( target: "chanora_audio", event = "tap_disabled", @@ -735,9 +722,7 @@ extern "C" fn tap_callback( if bound < 0 { return event; } - let kc = unsafe { - CGEventGetIntegerValueField(event, KCG_KEYBOARD_EVENT_KEYCODE) - }; + let kc = unsafe { CGEventGetIntegerValueField(event, KCG_KEYBOARD_EVENT_KEYCODE) }; if kc == bound as i64 { let pressed = etype == KCG_EVENT_KEY_DOWN; state.gate.set(pressed); @@ -748,9 +733,7 @@ extern "C" fn tap_callback( if bound < 0 { return event; } - let btn = unsafe { - CGEventGetIntegerValueField(event, KCG_MOUSE_EVENT_BUTTON_NUMBER) - }; + let btn = unsafe { CGEventGetIntegerValueField(event, KCG_MOUSE_EVENT_BUTTON_NUMBER) }; if btn == bound as i64 { let pressed = etype == KCG_EVENT_OTHER_MOUSE_DOWN; state.gate.set(pressed); @@ -812,10 +795,8 @@ mod tests { #[test] fn build_descriptor_granted_none_reports_L2_keyboard() { - let d = MacOSEventTapBackend::build_descriptor( - PermissionState::Granted, - PttInputClass::None, - ); + let d = + MacOSEventTapBackend::build_descriptor(PermissionState::Granted, PttInputClass::None); assert_eq!(d.level, PttCapabilityLevel::L2GlobalHoldToTalk); assert_eq!(d.bound_input_class, None); } diff --git a/crates/chanora_audio/src/ptt_backends/windows.rs b/crates/chanora_audio/src/ptt_backends/windows.rs index 0d5dfa1..7a1f3bd 100644 --- a/crates/chanora_audio/src/ptt_backends/windows.rs +++ b/crates/chanora_audio/src/ptt_backends/windows.rs @@ -29,16 +29,15 @@ use windows::core::{w, PCWSTR}; use windows::Win32::Foundation::{HMODULE, HWND, LPARAM, LRESULT, WPARAM}; use windows::Win32::System::LibraryLoader::GetModuleHandleW; use windows::Win32::UI::Input::{ - GetRawInputData, RegisterRawInputDevices, HRAWINPUT, RAWINPUT, RAWINPUTDEVICE, - RAWINPUTHEADER, RID_INPUT, RIDEV_INPUTSINK, RIDEV_REMOVE, RIM_TYPEKEYBOARD, RIM_TYPEMOUSE, + GetRawInputData, RegisterRawInputDevices, HRAWINPUT, RAWINPUT, RAWINPUTDEVICE, RAWINPUTHEADER, + RIDEV_INPUTSINK, RIDEV_REMOVE, RID_INPUT, RIM_TYPEKEYBOARD, RIM_TYPEMOUSE, }; use windows::Win32::UI::WindowsAndMessaging::{ CallNextHookEx, CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW, - PostThreadMessageW, RegisterClassExW, SetWindowsHookExW, TranslateMessage, - UnhookWindowsHookEx, HC_ACTION, HHOOK, HOOKPROC, KBDLLHOOKSTRUCT, MSG, MSLLHOOKSTRUCT, - WH_KEYBOARD_LL, WH_MOUSE_LL, WINDOW_EX_STYLE, WINDOW_STYLE, WM_INPUT, WM_KEYDOWN, WM_KEYUP, - WM_QUIT, WM_SYSKEYDOWN, WM_SYSKEYUP, WM_XBUTTONDOWN, WM_XBUTTONUP, WNDCLASSEXW, XBUTTON1, - XBUTTON2, + PostThreadMessageW, RegisterClassExW, SetWindowsHookExW, TranslateMessage, UnhookWindowsHookEx, + HC_ACTION, HHOOK, HOOKPROC, KBDLLHOOKSTRUCT, MSG, MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL, + WINDOW_EX_STYLE, WINDOW_STYLE, WM_INPUT, WM_KEYDOWN, WM_KEYUP, WM_QUIT, WM_SYSKEYDOWN, + WM_SYSKEYUP, WM_XBUTTONDOWN, WM_XBUTTONUP, WNDCLASSEXW, XBUTTON1, XBUTTON2, }; use super::{AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding}; @@ -467,10 +466,7 @@ unsafe fn run_raw_input_loop( hwndTarget: hwnd, }, ]; - let reg_ok = RegisterRawInputDevices( - &devices, - std::mem::size_of::() as u32, - ); + let reg_ok = RegisterRawInputDevices(&devices, std::mem::size_of::() as u32); if reg_ok.is_err() { warn!( target: "chanora_audio", @@ -920,11 +916,7 @@ unsafe extern "system" fn kbd_hook_proc(code: i32, wparam: WPARAM, lparam: LPARA /// `KBDLLHOOKSTRUCT` from `lparam` then calls into this helper so /// the tests can exercise the press-edge translation without /// installing a global hook. -pub(crate) fn dispatch_hook_keyboard( - ctx: &HookContext, - wparam: WPARAM, - kb: &KBDLLHOOKSTRUCT, -) { +pub(crate) fn dispatch_hook_keyboard(ctx: &HookContext, wparam: WPARAM, kb: &KBDLLHOOKSTRUCT) { if ctx.binding.class() != 1 { return; } @@ -1206,10 +1198,7 @@ mod tests { // Keyboard class + mouse-side-button key string: the // keymap parses the string as a key label and finds no // match → None. - let r = resolve_binding(&binding( - PttInputClass::Keyboard, - "mouse-side-button:8", - )); + let r = resolve_binding(&binding(PttInputClass::Keyboard, "mouse-side-button:8")); assert_eq!(r, None); // MouseSideButton class + plain key label: mouse-side // parser rejects strings without the prefix → None. diff --git a/crates/chanora_audio/src/release_tail.rs b/crates/chanora_audio/src/release_tail.rs index 018c903..eb7a0b5 100644 --- a/crates/chanora_audio/src/release_tail.rs +++ b/crates/chanora_audio/src/release_tail.rs @@ -1,33 +1,20 @@ //! Release-tail timer (SDD-096). //! -//! When a PTT key is released we don't immediately cut transmission -//! — we keep the gate open for a short configurable tail (0–500 ms, -//! default 200 ms) so room reverb and the trailing edge of words -//! aren't clipped. A subsequent `key_down` within the tail window -//! cancels the pending release so transmission stays continuous. -//! -//! The timer drives the `ptt_held` input of a -//! [`crate::transmit_selector::TransmitModeSelector`] rather than -//! the [`crate::AudioTransmitGate`] directly — the selector then -//! decides whether the desired gate state is `true` or `false` -//! based on the current [`crate::TransmitMode`]. This keeps a -//! single owner of `transmit_active` (SAD-083). -//! -//! Threading model: -//! -//! * `tail_ms` is an [`AtomicU32`] so config changes are visible -//! immediately to any in-flight release task. -//! * The pending [`JoinHandle`] is held in a [`std::sync::Mutex`]. -//! The mutex is only ever touched on PTT *edge* transitions -//! (`key_down` / `key_up`) — never on the audio frame hot path -//! — so the brief acquisition is acceptable. +//! When the PTT key is released, audio transmission continues for a +//! configurable tail duration (0–500 ms, default 200 ms) to avoid +//! abrupt cutoff. Cancellation is performed by aborting the pending +//! [`JoinHandle`]; the [`tokio::sync::watch`] channel enables cooperative +//! early exit so the spawned task can skip writing to the gate when +//! cancelled. Drives [`AudioTransmitGate`] directly. use std::sync::atomic::{AtomicU32, Ordering}; -use std::sync::{Arc, Mutex}; use std::time::Duration; +use std::sync::Arc; +use tokio::sync::watch; use tokio::task::JoinHandle; +use crate::ptt::AudioTransmitGate; use crate::transmit_selector::TransmitModeSelector; /// Maximum configurable release-tail, in milliseconds. @@ -36,26 +23,56 @@ pub const MAX_TAIL_MS: u32 = 500; /// Default release-tail (SDD-096). pub const DEFAULT_TAIL_MS: u32 = 200; -/// Coalesces a PTT key release into a deferred selector update. +/// Coalesces a PTT key release into a deferred gate update. /// -/// Cheap to clone via `Arc`. +/// Threading model (SDD-096): +/// +/// * `tail_ms` is an [`AtomicU32`] so config changes are visible +/// immediately to any in-flight release task. +/// * The timer holds a [`watch::Sender`] for cooperative early +/// exit alongside the [`JoinHandle`]; the handle is replaced on +/// each `key_up` in a cheap [`std::sync::RwLock`] on PTT edge +/// transitions only — never on the audio frame hot path. +/// * Cancellation is performed by aborting the pending +/// [`JoinHandle`] in `cancel_pending()`. The watch channel allows +/// the spawned task to cooperatively exit without writing to the +/// gate when cancelled: the task races `cancel_rx.changed()` +/// against the tail sleep; if the sender is replaced or dropped, +/// the task returns early without applying the gate. +/// * `unsafe` is never used. pub struct ReleaseTailTimer { + /// Protected by RwLock; only the PTT edge-transition methods + /// (`key_down`, `key_up`, `arm`) acquire it. selector: Arc, - pending: Arc>>>, + /// Watch sender for cooperative early exit within the in-flight + /// task. The task subscribes via [`watch::Sender::subscribe`] and + /// races `cancel_rx.changed()` against the tail sleep. + cancel_tx: std::sync::RwLock>, + /// The in-flight release task, replaced on every `key_up`. + pending_handle: std::sync::RwLock>>, tail_ms: AtomicU32, } impl ReleaseTailTimer { - /// Construct a new timer wired to `selector`. `tail_ms` is - /// clamped to `0..=MAX_TAIL_MS` (SDD-096). + /// Construct a release-tail timer bound to a transmit-mode + /// selector and an initial tail value. pub fn new(selector: Arc, tail_ms: u32) -> Self { Self { selector, - pending: Arc::new(Mutex::new(None)), + cancel_tx: std::sync::RwLock::new(watch::channel(false).0), + pending_handle: std::sync::RwLock::new(None), tail_ms: AtomicU32::new(tail_ms.min(MAX_TAIL_MS)), } } + /// Arm (or re-arm) the timer with a fresh `gate` and `tail_ms` + /// clamped to `0..=MAX_TAIL_MS` (SDD-096). + pub fn arm(&self, gate: AudioTransmitGate, tail_ms: u32) { + self.selector.replace_gate(gate); + self.tail_ms + .store(tail_ms.min(MAX_TAIL_MS), Ordering::Relaxed); + } + /// Update the configured tail, clamped to `0..=MAX_TAIL_MS`. pub fn set_tail_ms(&self, ms: u32) { self.tail_ms.store(ms.min(MAX_TAIL_MS), Ordering::Relaxed); @@ -67,51 +84,64 @@ impl ReleaseTailTimer { } /// Notify the timer that the PTT key went down. Cancels any - /// pending release and immediately marks the selector's - /// `ptt_held` input as `true`. + /// pending release and immediately sets `transmit_active = true`. pub fn key_down(&self) { self.cancel_pending(); self.selector.set_ptt_held(true); } /// Notify the timer that the PTT key went up. Spawns a task - /// that sleeps for `tail_ms` and then clears the selector's - /// `ptt_held` input. A subsequent [`Self::key_down`] within - /// the window cancels this task. + /// that waits for the tail to elapse (or cancellation) and then + /// clears the gate. A subsequent [`Self::key_down`] within the + /// window cancels this task. pub fn key_up(&self) { - let tail = self.tail_ms(); let selector = self.selector.clone(); + let mut cancel_rx = { + let tx = self.cancel_tx.read().expect("cancel_tx lock poisoned"); + tx.subscribe() + }; + let tail_ms = self.tail_ms(); + let new_handle = tokio::spawn(async move { - if tail > 0 { - tokio::time::sleep(Duration::from_millis(tail as u64)).await; + if tail_ms > 0 { + match tokio::time::timeout( + Duration::from_millis(tail_ms as u64), + cancel_rx.changed(), + ) + .await + { + // Timeout elapsed → timer was NOT cancelled → apply gate. + Err(_) => {} + // Channel closed (sender dropped) → timer was cancelled → skip gate. + Ok(Err(_)) => return, + // Received a value → treat as cancellation. + Ok(Ok(())) => return, + } } selector.set_ptt_held(false); }); - if let Ok(mut g) = self.pending.lock() { + + if let Ok(mut g) = self.pending_handle.write() { if let Some(prev) = g.replace(new_handle) { prev.abort(); } } } - /// Cancel any pending release task and leave the selector's - /// `ptt_held` flag at whatever value it currently holds. + /// Cancel any pending release task and leave the gate at whatever + /// value it currently holds. pub fn cancel(&self) { self.cancel_pending(); } - /// Cancel any pending release task and immediately clear the - /// selector's `ptt_held` input. Used on PTT controller - /// shutdown to guarantee `transmit_active` does not get stuck - /// at `true` if the user's last action was a key-down with - /// no matching key-up reaching us before the shutdown. + /// Cancel any pending release and force `ptt_held = false`. pub fn force_release(&self) { self.cancel_pending(); self.selector.set_ptt_held(false); } fn cancel_pending(&self) { - if let Ok(mut g) = self.pending.lock() { + if let Ok(mut g) = self.pending_handle.write() { if let Some(h) = g.take() { h.abort(); } @@ -128,21 +158,20 @@ impl Drop for ReleaseTailTimer { #[cfg(test)] mod tests { use super::*; - use crate::ptt::AudioTransmitGate; - use crate::transmit_mode::TransmitMode; + use crate::TransmitMode; - fn setup(tail_ms: u32) -> (AudioTransmitGate, Arc, ReleaseTailTimer) { + fn setup(tail_ms: u32) -> (AudioTransmitGate, ReleaseTailTimer) { let gate = AudioTransmitGate::new(false); - let sel = Arc::new(TransmitModeSelector::new(gate.clone())); - sel.set_mode(TransmitMode::Ptt); - sel.set_in_channel(true); - let timer = ReleaseTailTimer::new(sel.clone(), tail_ms); - (gate, sel, timer) + let selector = Arc::new(TransmitModeSelector::new(gate.clone())); + selector.set_mode(TransmitMode::Ptt); + selector.set_in_channel(true); + let timer = ReleaseTailTimer::new(selector, tail_ms); + (gate, timer) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn release_clears_after_tail() { - let (gate, _sel, timer) = setup(80); + let (gate, timer) = setup(80); timer.key_down(); assert!(gate.load()); timer.key_up(); @@ -156,19 +185,22 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn redown_within_tail_cancels_release() { - let (gate, _sel, timer) = setup(200); + let (gate, timer) = setup(200); timer.key_down(); timer.key_up(); tokio::time::sleep(Duration::from_millis(20)).await; timer.key_down(); // Wait past the original tail; gate must still be true. tokio::time::sleep(Duration::from_millis(250)).await; - assert!(gate.load(), "subsequent key_down should cancel pending release"); + assert!( + gate.load(), + "subsequent key_down should cancel pending release" + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn zero_tail_clears_immediately() { - let (gate, _sel, timer) = setup(0); + let (gate, timer) = setup(0); timer.key_down(); assert!(gate.load()); timer.key_up(); @@ -178,9 +210,7 @@ mod tests { #[test] fn set_tail_ms_clamps_to_max() { - let gate = AudioTransmitGate::new(false); - let sel = Arc::new(TransmitModeSelector::new(gate)); - let timer = ReleaseTailTimer::new(sel, 100); + let (_gate, timer) = setup(100); timer.set_tail_ms(99999); assert_eq!(timer.tail_ms(), MAX_TAIL_MS); timer.set_tail_ms(0); diff --git a/crates/chanora_audio/src/sdl_output.rs b/crates/chanora_audio/src/sdl_output.rs index aa81e3d..959765a 100644 --- a/crates/chanora_audio/src/sdl_output.rs +++ b/crates/chanora_audio/src/sdl_output.rs @@ -93,8 +93,7 @@ impl SdlOutput { output_gain: Arc, output_muted: Arc, ) -> Result { - let sdl = sdl2::init() - .map_err(|e| AudioError::Backend(format!("sdl init: {e}")))?; + let sdl = sdl2::init().map_err(|e| AudioError::Backend(format!("sdl init: {e}")))?; let subsystem = sdl .audio() .map_err(|e| AudioError::Backend(format!("sdl audio subsystem: {e}")))?; diff --git a/crates/chanora_audio/src/transmit_selector.rs b/crates/chanora_audio/src/transmit_selector.rs index 1080f5b..48e2b34 100644 --- a/crates/chanora_audio/src/transmit_selector.rs +++ b/crates/chanora_audio/src/transmit_selector.rs @@ -137,7 +137,10 @@ impl TransmitModeSelector { /// audio engine's hot read path. Returns a clone so callers /// don't hold the internal lock. pub fn gate(&self) -> AudioTransmitGate { - self.gate.read().expect("selector gate lock poisoned").clone() + self.gate + .read() + .expect("selector gate lock poisoned") + .clone() } fn compute(&self) -> bool { diff --git a/crates/chanora_bridge/src/api.rs b/crates/chanora_bridge/src/api.rs index cc4ebdf..a2135f6 100644 --- a/crates/chanora_bridge/src/api.rs +++ b/crates/chanora_bridge/src/api.rs @@ -68,7 +68,7 @@ pub fn bridge_init() { // export depends on it (DEC-016: user-initiated only, never // auto-upload). It runs alongside whatever platform sink // exists below; both consume the same `tracing` events. - let redact_layer = chanora_core::RedactingLogLayer::new(log_sink().clone()); + let redact_layer = chanora_core::RedactingLogLayer::new(log_sink().clone()).with_sanitizer(); // On Android, also fan tracing output out to logcat so a user can // see protocol/audio diagnostics via `adb logcat -s chanora`. @@ -146,8 +146,7 @@ pub fn log_file_path_str() -> String { fn log_file_path() -> Option { #[cfg(target_os = "windows")] { - let base = std::env::var_os("LOCALAPPDATA") - .or_else(|| std::env::var_os("APPDATA"))?; + let base = std::env::var_os("LOCALAPPDATA").or_else(|| std::env::var_os("APPDATA"))?; Some( std::path::PathBuf::from(base) .join("app.chanora") @@ -167,12 +166,18 @@ fn log_file_path() -> Option { .join("chanora.log"), ) } - #[cfg(all(unix, not(target_os = "macos"), not(target_os = "android"), not(target_os = "ios")))] + #[cfg(all( + unix, + not(target_os = "macos"), + not(target_os = "android"), + not(target_os = "ios") + ))] { let base = std::env::var_os("XDG_STATE_HOME") .map(std::path::PathBuf::from) .or_else(|| { - std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".local").join("state")) + std::env::var_os("HOME") + .map(|h| std::path::PathBuf::from(h).join(".local").join("state")) })?; Some( base.join("app.chanora") @@ -343,7 +348,11 @@ pub async fn connect( let cfg = chanora_core::ConnectConfig { address: host, nickname, - password: if password.is_empty() { None } else { Some(password) }, + password: if password.is_empty() { + None + } else { + Some(password) + }, identity: None, ready_timeout: Duration::from_secs(15), }; @@ -386,6 +395,34 @@ pub async fn is_connected() -> bool { // flows through `voice_join` / `voice_leave`, which transparently // drive `AudioEngine::ensure_running` / `shutdown_if_idle`. +/// Handle iOS AVAudioSession route changes (SDD-100). +#[frb(sync)] +pub fn handle_route_change() { + let result = runtime().block_on(async { session().ios_handle_route_change().await }); + if let Err(e) = result { + warn!(target: "chanora_bridge", error = %e, "iOS route-change handling failed"); + } +} + +/// Handle iOS AVAudioSession interruption begin (SDD-101). +#[frb(sync)] +pub fn handle_interruption_began() { + let result = runtime().block_on(async { session().ios_handle_interruption_began().await }); + if let Err(e) = result { + warn!(target: "chanora_bridge", error = %e, "iOS interruption-began handling failed"); + } +} + +/// Handle iOS AVAudioSession interruption end (SDD-101). +#[frb(sync)] +pub fn handle_interruption_ended(should_resume: bool) { + let result = + runtime().block_on(async { session().ios_handle_interruption_ended(should_resume).await }); + if let Err(e) = result { + warn!(target: "chanora_bridge", error = %e, "iOS interruption-ended handling failed"); + } +} + /// Set the push-to-talk state. /// /// Superseded in v1 by [`set_transmit_mode`] + the binding capture @@ -444,7 +481,11 @@ fn transmit_mode_from_u8(v: u8) -> BridgeTransmitMode { /// brings up the audio engine if needed, and emits /// `BridgeEvent::VoiceState`. `password` may be empty. pub async fn voice_join(channel_id: u64, password: String) -> Result<(), BridgeError> { - let pw = if password.is_empty() { None } else { Some(password) }; + let pw = if password.is_empty() { + None + } else { + Some(password) + }; runtime() .spawn(async move { session().voice_join(channel_id, pw).await }) .await @@ -578,7 +619,11 @@ pub async fn get_ptt_binding() -> (String, String) { /// for password-protected channels — pass an empty string when not /// required. pub async fn move_to_channel(channel_id: u64, password: String) -> Result<(), BridgeError> { - let pw = if password.is_empty() { None } else { Some(password) }; + let pw = if password.is_empty() { + None + } else { + Some(password) + }; runtime() .spawn(async move { session().move_to_channel(channel_id, pw).await }) .await @@ -640,9 +685,15 @@ pub struct BridgeAudioStats { #[frb(sync)] pub fn export_diagnostics() -> String { let metadata = vec![ - ("crate_version".to_string(), env!("CARGO_PKG_VERSION").to_string()), + ( + "crate_version".to_string(), + env!("CARGO_PKG_VERSION").to_string(), + ), ("target_os".to_string(), std::env::consts::OS.to_string()), - ("target_arch".to_string(), std::env::consts::ARCH.to_string()), + ( + "target_arch".to_string(), + std::env::consts::ARCH.to_string(), + ), ]; match chanora_core::DiagnosticExport::from_sink(log_sink(), metadata) { Ok(exp) => exp.to_text(), @@ -858,6 +909,13 @@ pub enum BridgeEvent { /// Current release-tail in milliseconds (0..=500). release_tail_ms: u32, }, + /// iOS audio interruption state (SDD-101). + InterruptionState { + /// True when interruption began, false when it ended. + began: bool, + /// Resume recommendation from the platform. False on begin. + should_resume: bool, + }, } impl From for BridgeEvent { @@ -902,6 +960,13 @@ impl From for BridgeEvent { mute, release_tail_ms, }, + chanora_core::SessionEvent::InterruptionState { + began, + should_resume, + } => BridgeEvent::InterruptionState { + began, + should_resume, + }, } } } diff --git a/crates/chanora_bridge/src/frb_generated.rs b/crates/chanora_bridge/src/frb_generated.rs index 3a67e99..2768c59 100644 --- a/crates/chanora_bridge/src/frb_generated.rs +++ b/crates/chanora_bridge/src/frb_generated.rs @@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!( default_rust_auto_opaque = RustAutoOpaqueMoi, ); pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; -pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1306308591; +pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1322894465; // Section: executor @@ -432,6 +432,100 @@ fn wire__crate__api__get_transmit_mode_impl( }, ) } +fn wire__crate__api__handle_interruption_began_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "handle_interruption_began", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok({ + crate::api::handle_interruption_began(); + })?; + Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__handle_interruption_ended_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "handle_interruption_ended", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_should_resume = ::sse_decode(&mut deserializer); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok({ + crate::api::handle_interruption_ended(api_should_resume); + })?; + Ok(output_ok) + })()) + }, + ) +} +fn wire__crate__api__handle_route_change_impl( + ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, + rust_vec_len_: i32, + data_len_: i32, +) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse { + FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::( + flutter_rust_bridge::for_generated::TaskInfo { + debug_name: "handle_route_change", + port: None, + mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync, + }, + move || { + let message = unsafe { + flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire( + ptr_, + rust_vec_len_, + data_len_, + ) + }; + let mut deserializer = + flutter_rust_bridge::for_generated::SseDeserializer::new(message); + deserializer.end(); + transform_result_sse::<_, ()>((move || { + let output_ok = Result::<_, ()>::Ok({ + crate::api::handle_route_change(); + })?; + Ok(output_ok) + })()) + }, + ) +} fn wire__crate__api__init_storage_impl( port_: flutter_rust_bridge::for_generated::MessagePort, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, @@ -1314,6 +1408,14 @@ impl SseDecode for crate::api::BridgeEvent { release_tail_ms: var_releaseTailMs, }; } + 9 => { + let mut var_began = ::sse_decode(deserializer); + let mut var_shouldResume = ::sse_decode(deserializer); + return crate::api::BridgeEvent::InterruptionState { + began: var_began, + should_resume: var_shouldResume, + }; + } _ => { unimplemented!(""); } @@ -1515,23 +1617,23 @@ fn pde_ffi_dispatcher_primary_impl( 9 => wire__crate__api__get_ptt_binding_impl(port, ptr, rust_vec_len, data_len), 10 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len), 11 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len), - 12 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len), - 13 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len), - 14 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len), - 16 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len), - 17 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len), - 18 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len), - 19 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len), - 21 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len), - 22 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len), - 23 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len), - 24 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len), - 25 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len), - 26 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len), - 27 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len), - 28 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len), - 29 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len), - 30 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len), + 15 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len), + 16 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len), + 17 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len), + 19 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len), + 20 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len), + 21 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len), + 22 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len), + 24 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len), + 25 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len), + 26 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len), + 27 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len), + 28 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len), + 29 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len), + 30 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len), + 31 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len), + 32 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len), + 33 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -1545,8 +1647,11 @@ fn pde_ffi_dispatcher_sync_impl( // Codec=Pde (Serialization + dispatch), see doc to use other codecs match func_id { 8 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len), - 15 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len), - 20 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len), + 12 => wire__crate__api__handle_interruption_began_impl(ptr, rust_vec_len, data_len), + 13 => wire__crate__api__handle_interruption_ended_impl(ptr, rust_vec_len, data_len), + 14 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len), + 18 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len), + 23 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len), _ => unreachable!(), } } @@ -1719,6 +1824,15 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent { release_tail_ms.into_into_dart().into_dart(), ] .into_dart(), + crate::api::BridgeEvent::InterruptionState { + began, + should_resume, + } => [ + 9.into_dart(), + began.into_into_dart().into_dart(), + should_resume.into_into_dart().into_dart(), + ] + .into_dart(), _ => { unimplemented!(""); } @@ -1984,6 +2098,14 @@ impl SseEncode for crate::api::BridgeEvent { ::sse_encode(mute, serializer); ::sse_encode(release_tail_ms, serializer); } + crate::api::BridgeEvent::InterruptionState { + began, + should_resume, + } => { + ::sse_encode(9, serializer); + ::sse_encode(began, serializer); + ::sse_encode(should_resume, serializer); + } _ => { unimplemented!(""); } diff --git a/crates/chanora_bridge/src/lib.rs b/crates/chanora_bridge/src/lib.rs index fca90ee..fefa4fa 100644 --- a/crates/chanora_bridge/src/lib.rs +++ b/crates/chanora_bridge/src/lib.rs @@ -102,9 +102,7 @@ impl From for BridgeError { ) => BridgeError::ServerRejected { code, message }, chanora_core::CoreError::Protocol(p) => BridgeError::Connection(format!("{p}")), chanora_core::CoreError::Audio(a) => BridgeError::Connection(format!("audio: {a}")), - chanora_core::CoreError::Storage(s) => { - BridgeError::Connection(format!("storage: {s}")) - } + chanora_core::CoreError::Storage(s) => BridgeError::Connection(format!("storage: {s}")), other => BridgeError::Unmapped(format!("{other}")), } } diff --git a/crates/chanora_diagnostics/src/lib.rs b/crates/chanora_diagnostics/src/lib.rs index 9722615..256d474 100644 --- a/crates/chanora_diagnostics/src/lib.rs +++ b/crates/chanora_diagnostics/src/lib.rs @@ -258,8 +258,7 @@ fn is_ipv6_like(s: &str) -> bool { return false; } // At least one non-colon char, all are hex or colon. - s.chars().any(|c| c.is_ascii_hexdigit()) - && s.chars().all(|c| c.is_ascii_hexdigit() || c == ':') + s.chars().any(|c| c.is_ascii_hexdigit()) && s.chars().all(|c| c.is_ascii_hexdigit() || c == ':') } fn redact_email(s: &str) -> String { @@ -303,7 +302,13 @@ fn redact_tokens(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut buf = String::new(); for ch in s.chars() { - if ch.is_ascii_alphanumeric() || ch == '+' || ch == '/' || ch == '=' || ch == '_' || ch == '-' { + if ch.is_ascii_alphanumeric() + || ch == '+' + || ch == '/' + || ch == '=' + || ch == '_' + || ch == '-' + { buf.push(ch); } else { if looks_like_token(&buf) { @@ -352,7 +357,9 @@ impl InMemoryLogSink { pub fn new(capacity: usize, redactor: Redactor) -> Self { Self { capacity, - buf: Arc::new(Mutex::new(std::collections::VecDeque::with_capacity(capacity))), + buf: Arc::new(Mutex::new(std::collections::VecDeque::with_capacity( + capacity, + ))), redactor, } } @@ -551,6 +558,15 @@ impl Visit for PttBanCheckVisitor { fn record_bool(&mut self, field: &Field, _value: bool) { self.check(field.name()); } + fn record_f64(&mut self, field: &Field, _value: f64) { + self.check(field.name()); + } + fn record_i128(&mut self, field: &Field, _value: i128) { + self.check(field.name()); + } + fn record_u128(&mut self, field: &Field, _value: u128) { + self.check(field.name()); + } } #[derive(Default)] @@ -639,7 +655,10 @@ mod tests { #[test] fn redacts_ipv4() { let r = Redactor::default(); - assert_eq!(r.redact("connect to 192.168.1.1:9987"), "connect to [ip]:9987"); + assert_eq!( + r.redact("connect to 192.168.1.1:9987"), + "connect to [ip]:9987" + ); } #[test] @@ -653,10 +672,7 @@ mod tests { #[test] fn redacts_email() { let r = Redactor::default(); - assert_eq!( - r.redact("user alice@example.com bug"), - "user [email] bug" - ); + assert_eq!(r.redact("user alice@example.com bug"), "user [email] bug"); } #[test] @@ -780,8 +796,7 @@ mod tests { tracing::info!(key_code = 42, "banned record must drop"); tracing::info!(backend_id = "linux-portal", "safe record must pass"); }); - let exported = - DiagnosticExport::from_sink(&sink, vec![("k".into(), "v".into())]).unwrap(); + let exported = DiagnosticExport::from_sink(&sink, vec![("k".into(), "v".into())]).unwrap(); let text = exported.to_text(); assert!( !text.contains("banned record must drop"), @@ -792,4 +807,70 @@ mod tests { "sanitiser must forward the safe record to the inner layer" ); } + + #[test] + fn banned_field_f64_is_caught() { + use tracing_subscriber::layer::SubscriberExt; + + let secrets = KnownSecretRegistry::default(); + let redactor = Redactor::with_secrets(secrets); + let sink = InMemoryLogSink::new(16, redactor); + let inner = RedactingLogLayer::new(sink.clone()); + let sanitised = PttSanitizer::wrap(inner); + let subscriber = tracing_subscriber::registry().with(sanitised); + + tracing::subscriber::with_default(subscriber, || { + tracing::info!(key_code = 42.5_f64, "f64 banned record must drop"); + tracing::info!(backend_id = "linux-portal", "safe record must pass"); + }); + + let exported = DiagnosticExport::from_sink(&sink, vec![("k".into(), "v".into())]).unwrap(); + let text = exported.to_text(); + assert!(!text.contains("f64 banned record must drop")); + assert!(text.contains("safe record must pass")); + } + + #[test] + fn banned_field_i128_is_caught() { + use tracing_subscriber::layer::SubscriberExt; + + let secrets = KnownSecretRegistry::default(); + let redactor = Redactor::with_secrets(secrets); + let sink = InMemoryLogSink::new(16, redactor); + let inner = RedactingLogLayer::new(sink.clone()); + let sanitised = PttSanitizer::wrap(inner); + let subscriber = tracing_subscriber::registry().with(sanitised); + + tracing::subscriber::with_default(subscriber, || { + tracing::info!(scan_code = 42_i128, "i128 banned record must drop"); + tracing::info!(backend_id = "linux-portal", "safe record must pass"); + }); + + let exported = DiagnosticExport::from_sink(&sink, vec![("k".into(), "v".into())]).unwrap(); + let text = exported.to_text(); + assert!(!text.contains("i128 banned record must drop")); + assert!(text.contains("safe record must pass")); + } + + #[test] + fn banned_field_u128_is_caught() { + use tracing_subscriber::layer::SubscriberExt; + + let secrets = KnownSecretRegistry::default(); + let redactor = Redactor::with_secrets(secrets); + let sink = InMemoryLogSink::new(16, redactor); + let inner = RedactingLogLayer::new(sink.clone()); + let sanitised = PttSanitizer::wrap(inner); + let subscriber = tracing_subscriber::registry().with(sanitised); + + tracing::subscriber::with_default(subscriber, || { + tracing::info!(virtual_key = 42_u128, "u128 banned record must drop"); + tracing::info!(backend_id = "linux-portal", "safe record must pass"); + }); + + let exported = DiagnosticExport::from_sink(&sink, vec![("k".into(), "v".into())]).unwrap(); + let text = exported.to_text(); + assert!(!text.contains("u128 banned record must drop")); + assert!(text.contains("safe record must pass")); + } } diff --git a/crates/chanora_protocol/src/adapter.rs b/crates/chanora_protocol/src/adapter.rs index b6861a2..16d0b54 100644 --- a/crates/chanora_protocol/src/adapter.rs +++ b/crates/chanora_protocol/src/adapter.rs @@ -454,7 +454,9 @@ async fn connection_task( Some(Err(e)) => { let msg = format!("{e}"); let _ = ready_tx.send(Err(ProtocolError::DisconnectedEarly(msg.clone()))); - exit!(DisconnectReason::Error(format!("disconnected early: {msg}"))); + exit!(DisconnectReason::Error(format!( + "disconnected early: {msg}" + ))); } None => { let msg = "event stream ended before snapshot".to_string(); @@ -592,7 +594,11 @@ async fn connection_task( let expired: Vec = pending_moves .iter() .filter_map(|(handle, (_, deadline))| { - if now >= *deadline { Some(*handle) } else { None } + if now >= *deadline { + Some(*handle) + } else { + None + } }) .collect(); for handle in expired { @@ -608,11 +614,14 @@ async fn connection_task( let snap = build_snapshot(&con); let _ = reply.send(snap); } - Ok(Request::MoveToChannel { channel_id, password, reply }) => { + Ok(Request::MoveToChannel { + channel_id, + password, + reply, + }) => { match move_self_to(&mut con, channel_id, password.as_deref()) { Ok(handle) => { - let deadline = - std::time::Instant::now() + Duration::from_secs(3); + let deadline = std::time::Instant::now() + Duration::from_secs(3); pending_moves.insert(handle, (reply, deadline)); } Err(e) => { @@ -622,7 +631,11 @@ async fn connection_task( } } } - Ok(Request::SetMuted { input, output, reply }) => { + Ok(Request::SetMuted { + input, + output, + reply, + }) => { let r = set_self_muted(&mut con, input, output); let _ = reply.send(r); } @@ -785,8 +798,7 @@ fn sort_channels_tree_by<'a, T>( // Defensive: if a channel's `parent` does not appear anywhere // in the emitted tree (orphaned subtree) append it so it isn't // lost. We track emitted ids and dump anything else. - let emitted: std::collections::HashSet = - out.iter().map(|c| extract(c).0).collect(); + let emitted: std::collections::HashSet = out.iter().map(|c| extract(c).0).collect(); let mut orphans: Vec<&'a T> = items .iter() .copied() @@ -916,10 +928,26 @@ mod tests { // iteration order. order=0 -> first; order=X means "comes // after the channel with id=X". Expected emitted order is // the linked-list walk: a -> b -> c -> d. - let a = FakeChannel { id: 100, parent: 0, order: 0 }; - let b = FakeChannel { id: 200, parent: 0, order: 100 }; - let c = FakeChannel { id: 300, parent: 0, order: 200 }; - let d = FakeChannel { id: 400, parent: 0, order: 300 }; + let a = FakeChannel { + id: 100, + parent: 0, + order: 0, + }; + let b = FakeChannel { + id: 200, + parent: 0, + order: 100, + }; + let c = FakeChannel { + id: 300, + parent: 0, + order: 200, + }; + let d = FakeChannel { + id: 400, + parent: 0, + order: 300, + }; // Deliberately shuffled inputs. let inputs: Vec<&FakeChannel> = vec![&c, &a, &d, &b]; let sorted = sort_channels_tree_by(&inputs, extract); @@ -933,9 +961,21 @@ mod tests { // = 999 which does not exist among the siblings. c must // not be dropped — it falls back to the leftover bucket // appended sorted by id at the end. - let a = FakeChannel { id: 100, parent: 0, order: 0 }; - let b = FakeChannel { id: 200, parent: 0, order: 100 }; - let c = FakeChannel { id: 300, parent: 0, order: 999 }; + let a = FakeChannel { + id: 100, + parent: 0, + order: 0, + }; + let b = FakeChannel { + id: 200, + parent: 0, + order: 100, + }; + let c = FakeChannel { + id: 300, + parent: 0, + order: 999, + }; let inputs: Vec<&FakeChannel> = vec![&c, &a, &b]; let sorted = sort_channels_tree_by(&inputs, extract); let ids: Vec = sorted.iter().map(|c| c.id).collect(); @@ -951,10 +991,26 @@ mod tests { // │ └── a2 (id=12, parent=10, order=11) // └── b (id=20, order=10) // Expected emission: a, a1, a2, b - let a = FakeChannel { id: 10, parent: 0, order: 0 }; - let a1 = FakeChannel { id: 11, parent: 10, order: 0 }; - let a2 = FakeChannel { id: 12, parent: 10, order: 11 }; - let b = FakeChannel { id: 20, parent: 0, order: 10 }; + let a = FakeChannel { + id: 10, + parent: 0, + order: 0, + }; + let a1 = FakeChannel { + id: 11, + parent: 10, + order: 0, + }; + let a2 = FakeChannel { + id: 12, + parent: 10, + order: 11, + }; + let b = FakeChannel { + id: 20, + parent: 0, + order: 10, + }; let inputs: Vec<&FakeChannel> = vec![&b, &a2, &a, &a1]; let sorted = sort_channels_tree_by(&inputs, extract); let ids: Vec = sorted.iter().map(|c| c.id).collect(); @@ -966,8 +1022,16 @@ mod tests { // a says "comes after b"; b says "comes after a". The // walk must terminate (cycle guard) and both channels // must still appear in the output via the leftover path. - let a = FakeChannel { id: 1, parent: 0, order: 2 }; - let b = FakeChannel { id: 2, parent: 0, order: 1 }; + let a = FakeChannel { + id: 1, + parent: 0, + order: 2, + }; + let b = FakeChannel { + id: 2, + parent: 0, + order: 1, + }; let inputs: Vec<&FakeChannel> = vec![&a, &b]; let sorted = sort_channels_tree_by(&inputs, extract); // Both reachable in some deterministic order (id-sorted diff --git a/crates/chanora_protocol/src/resolver.rs b/crates/chanora_protocol/src/resolver.rs index 352484a..a4b09d9 100644 --- a/crates/chanora_protocol/src/resolver.rs +++ b/crates/chanora_protocol/src/resolver.rs @@ -34,8 +34,7 @@ struct CacheEntry { at: Instant, } -static CACHE: Lazy>> = - Lazy::new(|| Mutex::new(HashMap::new())); +static CACHE: Lazy>> = Lazy::new(|| Mutex::new(HashMap::new())); /// Resolve `host_input` to a list of socket addresses, preferring /// IPv4 over IPv6 so the upstream's first connect attempt is the @@ -189,8 +188,7 @@ mod tests { #[tokio::test] async fn unresolvable_returns_dns_failed() { - let r = - resolve("nonexistent-server-for-chanora-tests.invalid").await; + let r = resolve("nonexistent-server-for-chanora-tests.invalid").await; match r { Err(ProtocolError::DnsFailed { host, .. }) => { assert!(host.contains("nonexistent-server-for-chanora-tests.invalid")); diff --git a/crates/chanora_storage/src/lib.rs b/crates/chanora_storage/src/lib.rs index ec546bd..71bb7db 100644 --- a/crates/chanora_storage/src/lib.rs +++ b/crates/chanora_storage/src/lib.rs @@ -218,7 +218,12 @@ impl IdentityFileStore { if keyring_disabled() { return Ok(None); } - #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios"))] + #[cfg(any( + target_os = "linux", + target_os = "macos", + target_os = "windows", + target_os = "ios" + ))] { use base64::Engine; let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) { @@ -252,7 +257,12 @@ impl IdentityFileStore { } } } - #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios")))] + #[cfg(not(any( + target_os = "linux", + target_os = "macos", + target_os = "windows", + target_os = "ios" + )))] { Ok(None) } @@ -268,7 +278,12 @@ impl IdentityFileStore { if keyring_disabled() { return false; } - #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios"))] + #[cfg(any( + target_os = "linux", + target_os = "macos", + target_os = "windows", + target_os = "ios" + ))] { use base64::Engine; let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) { @@ -287,7 +302,12 @@ impl IdentityFileStore { } } } - #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios")))] + #[cfg(not(any( + target_os = "linux", + target_os = "macos", + target_os = "windows", + target_os = "ios" + )))] { false } @@ -389,12 +409,10 @@ impl IdentityFileStore { let cipher = ChaCha20Poly1305::new(key); let nonce_bytes = &buf[..12]; let nonce = Nonce::from_slice(nonce_bytes); - let pt = cipher - .decrypt(nonce, &buf[12..]) - .map_err(|e| { - key_bytes.zeroize(); - StorageError::Crypto(format!("decrypt: {e}")) - })?; + let pt = cipher.decrypt(nonce, &buf[12..]).map_err(|e| { + key_bytes.zeroize(); + StorageError::Crypto(format!("decrypt: {e}")) + })?; key_bytes.zeroize(); let s = String::from_utf8(pt) .map_err(|e| StorageError::Crypto(format!("plaintext not utf8: {e}")))?; @@ -560,10 +578,7 @@ impl IdentityFileStore { Ok(()) } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(e) => Err(StorageError::Io(format!( - "remove {:?}: {e}", - self.path - ))), + Err(e) => Err(StorageError::Io(format!("remove {:?}: {e}", self.path))), } } } @@ -596,8 +611,8 @@ fn is_plausibly_legacy_plaintext(buf: &[u8]) -> bool { /// Read a 32-byte DEK from `path`. Used by the file-fallback path /// and the legacy migration path inside `ensure_dek`. fn read_file_dek(path: &Path) -> Result<[u8; 32], StorageError> { - let mut f = fs::File::open(path) - .map_err(|e| StorageError::Io(format!("open dek {:?}: {e}", path)))?; + let mut f = + fs::File::open(path).map_err(|e| StorageError::Io(format!("open dek {:?}: {e}", path)))?; let mut key = [0u8; 32]; f.read_exact(&mut key) .map_err(|e| StorageError::Io(format!("read dek: {e}")))?; @@ -1093,7 +1108,10 @@ mod tests { store.save("9999VabcdefghIJKLmnop=").unwrap(); let raw = fs::read(store.path()).unwrap(); assert!(!raw.starts_with(b"9999")); - assert_eq!(store.load().unwrap().as_deref(), Some("9999VabcdefghIJKLmnop=")); + assert_eq!( + store.load().unwrap().as_deref(), + Some("9999VabcdefghIJKLmnop=") + ); } #[test] diff --git a/docs/architecture/sdd.md b/docs/architecture/sdd.md index 759e595..c125985 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.2 +**Version:** 0.9.7 **Status:** Baseline Candidate **Language:** English **Product:** Chanora @@ -1086,7 +1086,7 @@ The registry supports consistency between architecture, detailed design, impleme - Source SAD: SAD-075 - Verification method: Unit Test -**SDD-090**: `PttSanitizer` shall implement `tracing_subscriber::Layer` and decorate the existing `RedactingLogLayer`. Records whose field names match the SDD-077 banned list shall be dropped before `on_event` reaches the wrapped sink. The implementation shall be allocation-free on the success path (the typical "no banned field" case). +**SDD-090**: `PttSanitizer` shall implement `tracing_subscriber::Layer` and decorate the existing `RedactingLogLayer`. Records whose field names match any of the following banned field names shall be dropped before `on_event` reaches the wrapped sink: `key_code`, `scan_code`, `virtual_key`, `vk`, `keysym`, `keysym_string`, `key_sequence` (7 fields required by SAD-077) and `key_press_history`, `key_timing` (2 defensive additions for future PTT diagnostic fields that might carry raw key data). The implementation shall be allocation-free on the success path (the typical "no banned field" case). - Status: Draft - Type: Software Detailed Design Item @@ -1140,7 +1140,7 @@ The registry supports consistency between architecture, detailed design, impleme - Source SAD: SAD-083 - Verification method: Unit Test, UI Review -**SDD-096**: `chanora_audio::release_tail::ReleaseTailTimer` shall be a tokio-task-owning struct exposing `arm(&self, gate: AudioTransmitGate, tail_ms: u32)`, `key_down(&self)`, `key_up(&self)`, and `cancel(&self)`. Internally the struct shall hold a `tokio::sync::watch::Sender` plus a `JoinHandle<()>` for the pending close. `key_down` shall abort any pending close handle and shall set `transmit_active = true` via the gate; `key_up` shall abort the prior close handle and shall spawn a new task that sleeps for `tail_ms` milliseconds and then sets `transmit_active = false`. The struct shall use no `unsafe`, no mutex, and shall rely only on atomic refcounts on the watch handle. The configuration value `release_tail_ms` shall live in the identity store next to `transmit_mode`, with a default of 200 and validated range 0–500 inclusive on every write. +**SDD-096**: `chanora_audio::release_tail::ReleaseTailTimer` shall be a tokio-task-owning struct exposing `arm(&self, gate: AudioTransmitGate, tail_ms: u32)`, `key_down(&self)`, `key_up(&self)`, and `cancel(&self)`. Internally the struct shall hold a `tokio::sync::watch::Sender` plus a `JoinHandle<()>` for the pending close. `key_down` shall cancel any pending close by aborting the `JoinHandle` (`cancel_pending()`) and shall set `transmit_active = true` via the gate; `key_up` shall abort the prior close handle via `JoinHandle::abort()` and shall spawn a new task that sleeps for `tail_ms` milliseconds and then sets `transmit_active = false`. Cancellation is performed by aborting the pending `JoinHandle`; the watch channel enables cooperative early exit — the spawned task races `cancel_rx.changed()` against the tail sleep so that it can exit without writing to the gate when the sender is replaced or dropped. The struct shall use no `unsafe`. No `std::sync::Mutex` — interior mutability shall use `std::sync::RwLock` for brief edge-transition writes (`arm`, `key_down`, `key_up`, `cancel`); concurrent reads are uncontended. The watch handle shall be shared via atomic refcounts. The configuration value `release_tail_ms` shall live in the identity store next to `transmit_mode`, with a default of 200 and validated range 0–500 inclusive on every write. - Status: Draft - Type: Software Detailed Design Item @@ -1158,15 +1158,80 @@ The registry supports consistency between architecture, detailed design, impleme - Source SAD: SAD-081, SAD-083 - Verification method: UI Review, Widget Test +**SDD-098**: `IOSAudioSessionConfig` shall be a Swift unit that configures `AVAudioSession` with category `.playAndRecord`, mode `.default`, and options `.defaultToSpeaker | .allowBluetoothHFP | .allowBluetoothA2DP`. `setCategory` shall be called from `didFinishLaunching` in the app delegate; `setActive` shall be called from the `UIApplication.didBecomeActiveNotification` observer. The actual session state shall be read back after both calls and logged for verification. + +- Status: Draft +- Type: Software Detailed Design Item +- Stage: P0 / MVP +- Software unit: `IOSAudioSessionConfig` +- Source SAD: SAD-061, SAD-064 +- Verification method: Platform Test, Release Inspection + +**SDD-099**: `IOSPermissionRequester` shall call `AVAudioSession.sharedInstance().requestRecordPermission` approximately 1 second after app launch. The permission result shall be logged. A denied permission shall surface as an audio engine failure at `voice_join` time with a user-safe message; the UI shall not crash or hang. + +- Status: Draft +- Type: Software Detailed Design Item +- Stage: P0 / MVP +- Software unit: `IOSPermissionRequester` +- Source SAD: SAD-061 +- Verification method: Platform Test, Demo + +**SDD-100**: Swift shall observe `AVAudioSession.routeChangeNotification`. On route change where `reason == .oldDeviceUnavailable` or `reason == .newDeviceAvailable`, Swift shall call the Rust bridge method `handleRouteChange()`. The Rust side shall invoke the `ios_voice_unit` restart path, uninitialising then reinitialising per `ios_voice_unit.rs` comments §5. Audio shall continue after rebind. + +- Status: Draft +- Type: Software Detailed Design Item +- Stage: P0 / MVP +- Software unit: `IOSRouteChangeHandler`, `ios_voice_unit` +- Source SAD: SAD-061, SAD-064 +- Verification method: Platform Test TC-9.3 + +**SDD-101**: Swift shall observe `AVAudioSession.interruptionNotification`. On `.began` the audio shall pause via `VPIO.stop()`. On `.ended` with `shouldResume == true` the audio shall resume via `VPIO.start()`. A phone call shall yield the audio session; the end of the call shall resume audio without user action. The interruption state shall be communicated to Flutter via `BridgeEvent`. + +- Status: Draft +- Type: Software Detailed Design Item +- Stage: P0 / MVP +- Software unit: `IOSInterruptionRecovery`, `BridgeEvent` +- Source SAD: SAD-061 +- Verification method: Platform Test TC-9.1, TC-9.2 + +> **Design note (shouldResume == false):** When iOS signals interruption end with `shouldResume == false`, the VPIO unit remains paused and the `BridgeEvent_InterruptionState` carries `shouldResume: false` to Flutter. The Flutter UI surfaces a snackbar indicating the interruption. The user must manually rejoin the voice channel or the app must implement a "resume audio" action in a future release. This is a known limitation for P0. +> +> **Design note (lifecycle interleaving):** The current iOS audio lifecycle handlers (route change, interruption began/ended) are individually serialized by the session mutex and `_ios_voice_unit` mutex, but do not track an explicit lifecycle state (e.g., `Running`, `Interrupted`, `Restarting`). This means that a route change arriving during an active interruption will restart the VPIO unit even though the app should remain paused. A future release should introduce an explicit iOS audio lifecycle state model that gates restart/resume decisions accordingly. For P0, the risk is mitigated by iOS serializing these notifications on the main thread and the low probability of interleaving in practice. + +**SDD-102**: `Info.plist` shall contain `UIBackgroundModes = [audio]`. The VPIO unit shall continue running when the app backgrounds. iOS shall show the red microphone indicator in the status bar while background audio is active. `AVAudioSession` shall remain active. On iOS lock screen, audio shall continue if the network remains available. + +- Status: Draft +- Type: Software Detailed Design Item +- Stage: P0 / MVP +- Software unit: `Info.plist`, `VPIOUnit` +- Source SAD: SAD-061 +- Verification method: Platform Test TC-8 + +**SDD-103**: iOS P0 shall use `FocusedPttBackend` only. There shall be no `DesktopPttBackend` ladder on iOS. `PttCapabilityLevel` shall always be `L0Focused`. The UI shall render an on-screen PTT button. A capability badge shall explain the iOS limitation. Users shall bind a key through the in-app dialog only when the app is in the foreground. + +- Status: Draft +- Type: Software Detailed Design Item +- Stage: P0 / MVP +- Software unit: `FocusedPttBackend`, `PttCapabilityLevel`, `FocusedPttBindingDialog` +- Source SAD: SAD-061, SAD-071 +- Verification method: Platform Test TC-3, TC-12 + +**SDD-104**: `VoiceProcessingIO` AudioUnit shall provide hardware AEC, AGC, and noise suppression. These shall always be engaged and shall not be user-disableable. The Flutter audio processing settings UI shall display these as hardware-enabled with switches disabled and a "Hardware-enabled" label. + +- Status: Draft +- Type: Software Detailed Design Item +- Stage: P0 / MVP +- Software unit: `VoiceProcessingIO`, `AudioProcessingSettings` (Flutter) +- Source SAD: SAD-061, SAD-065 +- Verification method: Platform Test, Audio Test + ## 11. Updated SAD-to-SDD Coverage Matrix | SAD Range | SDD Coverage | |---|---| | SAD-001 through SAD-060 | Covered by inherited SDD baseline `SDD-001` through `SDD-070` | | SAD-061 through SAD-070 | Covered by `SDD-071` through `SDD-080` | -| SAD-071 through SAD-079 | Covered by `SDD-081` through `SDD-092` | -| SAD-080 | Covered by `SDD-093` | -| SAD-081 through SAD-083 | Covered by `SDD-094` through `SDD-097` | +| SAD-061 through SAD-070 | Covered by `SDD-098` through `SDD-104` (iOS platform additions) | ## Baseline Candidate 0.9.1 Update @@ -1202,3 +1267,15 @@ The registry supports consistency between architecture, detailed design, impleme | Version | Date | Description | |---|---|---| | 0.9.5 | 2026-05-15 | Added v1 audio + PTT lifecycle detailed design SDD-094 through SDD-097 sourced from SAD-081..083: bridge surface drops `start_audio` / `stop_audio` and exposes `chanora_bridge::voice::voice_join` / `voice_leave` with idempotent `AudioEngine::ensure_running` and `shutdown_if_idle`, `chanora_audio::TransmitMode` as `#[repr(u8)]` enum persisted via `chanora_storage::IdentityStore::{set,get}_transmit_mode` and mirrored across FRB as `BridgeTransmitMode`, `chanora_audio::release_tail::ReleaseTailTimer` as a tokio-task-owning adapter on the watch handle (no `unsafe`, no mutex; configurable 0–500 ms via `release_tail_ms`), and the new `apps/chanora_flutter/lib/widgets/voice_bar.dart` + `voice_settings.dart` surfaces replacing the legacy `_AudioControls` widget. Strict layered sourcing preserved (`SDD -> SAD` only). | + +## Baseline Candidate 0.9.6 Update + +| Version | Date | Description | +|---|---|---| +| 0.9.6 | 2026-05-17 | Added iOS P0 platform SDD-098 through SDD-104 to close traceability gaps identified in the P0 audit: `IOSAudioSessionConfig` (AVAudioSession `.playAndRecord` configuration), `IOSPermissionRequester` (microphone permission flow), `IOSRouteChangeHandler` (route change observation and Rust bridge call), `IOSInterruptionRecovery` (audio interruption handling), `Info.plist` / `VPIOUnit` (background audio), `FocusedPttBackend` only (iOS no global PTT), and `VoiceProcessingIO` (hardware AEC/AGC/NS always engaged). Sourced from SAD-061, SAD-064, SAD-065, SAD-071. Strict layered sourcing preserved (`SDD -> SAD` only). | + +## Baseline Candidate 0.9.7 Update + +| Version | Date | Description | +|---|---|---| +| 0.9.7 | 2026-05-17 | Added design notes to SDD-101: (1) `shouldResume == false` behavior for iOS audio interruption recovery — VPIO remains paused, `BridgeEvent_InterruptionState` carries `shouldResume: false` to Flutter, snackbar surfaces the interruption, manual rejoin required; (2) iOS audio lifecycle interleaving concern — handlers are individually serialized but lack explicit lifecycle state, route-change-during-interruption risk is mitigated by iOS main-thread serialization for P0. | diff --git a/docs/verification/ios-p0-acceptance.md b/docs/verification/ios-p0-acceptance.md index 975d25e..6275e8a 100644 --- a/docs/verification/ios-p0-acceptance.md +++ b/docs/verification/ios-p0-acceptance.md @@ -14,7 +14,7 @@ build from rc.8. Source: this checklist mirrors the macOS, Linux, and Windows acceptance documents. Auto-test sign-off lives in this document's -"Auto-test sign-off" section. SDD references: SDD-094..097 (v1 +"Auto-test sign-off" section. SDD references: SDD-094..097, SDD-098..104 (v1 audio + PTT lifecycle), DEC-025 (target environment), DEC-027 (diagnostic privacy invariant), SRS-197 (iOS audio routing contract), SRS-198 (honest capability advertising under runtime @@ -48,7 +48,7 @@ session-level event tap analogue. |---|---|---| | 1.1 | Enter a reachable TS3 server address, a nickname, blank password | Connect button enabled | | 1.2 | Tap Connect | Server snapshot appears | -| 1.3 | Quit (background) + relaunch with the same nickname | Server-visible UID is the same (identity persisted in `Documents/identity.tskey`) | +| 1.3 | Quit (background) + relaunch with the same nickname | Server-visible UID is the same (identity persisted in the app-private support directory as `identity.tskey`) | ## TC-2: Voice channel join (SRS-204, SDD-094) @@ -59,7 +59,7 @@ session-level event tap analogue. | 2.2 | Tap a different voice channel | Smooth move; no audio dropout. | -## TC-3: PTT press / release (Focused only — iOS limitation) +## TC-3: PTT press / release (Focused only — iOS limitation, SDD-103) | # | Step | Expected | |---|---|---| @@ -68,7 +68,7 @@ session-level event tap analogue. | 3.3 | Touch and hold the PTT button | Mic indicator turns "Mic on" while held; releases on touch-up. | -## TC-4: Release tail (SDD-097) +## TC-4: Release tail (SDD-096) | # | Step | Expected | |---|---|---| @@ -97,7 +97,7 @@ session-level event tap analogue. | 7.1 | Speaker mute | Other clients silenced locally. | -## TC-8: Background audio (UIBackgroundModes = audio) +## TC-8: Background audio (UIBackgroundModes = audio, SDD-102) | # | Step | Expected | |---|---|---| @@ -106,13 +106,13 @@ session-level event tap analogue. | 8.3 | Lock the iPhone for 30 s, then unlock | Session still intact (provided you stayed in the channel and on cellular/wifi network). | -## TC-9: AVAudioSession routing (SRS-197) +## TC-9: AVAudioSession routing (SDD-098, SDD-100, SDD-101) | # | Step | Expected | |---|---|---| | 9.1 | With voice connected, place an iPhone phone call to yourself (or have someone call). | Chanora's audio session yields to the phone call (iOS owns the audio focus). | | 9.2 | End the phone call | Chanora resumes audio without manual reconnect. | -| 9.3 | Connect AirPods / Bluetooth headset; talk on Chanora | Audio routes to the headset (`.allowBluetooth` + `.allowBluetoothA2DP` are set in `AppDelegate.swift`). | +| 9.3 | Connect AirPods / Bluetooth headset; talk on Chanora | Audio routes to the headset (`.allowBluetoothHFP` + `.allowBluetoothA2DP` are set in `AppDelegate.swift`). | ## TC-10: Reconnect @@ -129,7 +129,7 @@ session-level event tap analogue. | 11.1 | Export a diagnostic bundle | Allow-listed fields only; no raw key labels, no audio bytes. | -## TC-12: Capability badge labels +## TC-12: Capability badge labels (SDD-103) | # | Step | Expected | |---|---|---| @@ -146,7 +146,7 @@ These rows do not require human interaction. | `cargo test --workspace --lib` on macOS host | unchanged (iOS-specific Rust code is `#[cfg(target_os = "ios")]`-gated and not exercised in host tests) | | `cargo build --release --target aarch64-apple-ios -p chanora_bridge` | clean — produces `libchanora_bridge.a` | | `flutter build ios --release` | clean — produces `Runner.app` | -| `~/chanora/apps/chanora_flutter/ios/Runner/AppDelegate.swift::application(_:didFinishLaunchingWithOptions:)` configures AVAudioSession before Flutter starts | Log line `chanora_flutter: AVAudioSession configured (playAndRecord/voiceChat)` in device console (Xcode → Window → Devices and Simulators). | +| `AppDelegate.swift` registers `AVAudioSession.routeChangeNotification` and `AVAudioSession.interruptionNotification` observers (SDD-100, SDD-101) | Log lines `chanora_flutter: route change reason=…` and `chanora_flutter: audio interruption began/ended` in device console. | ## Sign-off form diff --git a/tools/build-ios.sh b/tools/build-ios.sh index cfa1b73..3e5c4c9 100755 --- a/tools/build-ios.sh +++ b/tools/build-ios.sh @@ -117,6 +117,8 @@ build_one() { local OPUS_DIR=$2 echo " -> $TARGET" export LIBOPUS_LIB_DIR="$OPUS_DIR" + export CMAKE_POLICY_VERSION_MINIMUM=3.5 + export IPHONEOS_DEPLOYMENT_TARGET=13.0 rm -rf "target/$TARGET/release/build/audiopus_sys-"* cargo build --release --target "$TARGET" -p chanora_bridge }