* fix(ios-audio): add voice join session coordinator
* fix(ios-audio): activate session before voice joins
* docs(ios-audio): align activation lifecycle comments
* fix(ios-audio): keep session active when already-in-channel
The 'already in channel' server response (code 0x0302) is treated as a
successful join by _onJoinChannel: the user stays in the channel and
local state is updated to reflect the joined target. But the underlying
voiceJoin call still raises BridgeError_ServerRejected, which the
joinVoiceChannelWithIosAudioSession helper used to interpret as a join
failure and deactivate the iOS audio session. Result: the UI shows the
user as joined while the audio session is dead and capture/playback
remain silent.
Add an isJoinSuccess predicate to the ordering helper. When the
predicate matches, the helper rethrows (so the caller can still run its
success-on-already-joined branch) without deactivating the session.
Wire _onJoinChannel to pass _isAlreadyInChannel as the predicate so the
0x0302 path keeps the session active.
Adds two regression tests covering the success-on-rethrow and the
predicate-false-still-deactivates paths.
* docs(security): regenerate license inventories
Cargo inventory: pick up chanora_resolver bump from 0.1.0 to
0.2.0-beta.1 so it matches the workspace; also adds a trailing newline
so 'cargo about generate' is idempotent in CI license-drift checks.
Flutter inventory: pick up flutter_local_notifications (+ platform
interfaces) and timezone pulled in by the prior notification
permission work.
Adopt a call-scoped VoIP audio session lifecycle so other apps' audio is
not stopped while Chanora is idle and the in-call session does not get
clobbered by media-server resets unrelated to voice.
AppDelegate.swift
- Set .ambient + .mixWithOthers as the idle baseline so the app does not
hold a VoiceChat session when no call is active.
- Switch to .playAndRecord + .voiceChat + .mixWithOthers + .duckOthers
on demand via the new chanora/ios_audio_session MethodChannel, and
revert to .ambient on deactivate.
- Gate the media-services-reset rebuild on voiceSessionActive so a
stray reset during idle no longer reactivates VoiceChat.
ios_audio_session_controller.dart (new)
- Thin Dart wrapper around chanora/ios_audio_session with activate/
deactivate; no-op on non-iOS; swallows PlatformException to keep
audio start/stop resilient to platform-side races.
main.dart
- Activate the iOS audio session on BridgeEvent_AudioStarted, deactivate
on BridgeEvent_AudioStopped, fire-and-forget via unawaited().
ios_voice_unit.rs
- Add the 10 local bindings required by the render-callback closure
preamble (wav_recorder_for_render, render_recorder_active,
render_ref_len, render_ref_accum, cb_count, last_num_frames,
num_frames_changes, callbacks_with_audio, callbacks_with_silence)
so the iOS target compiles cleanly with the new lifecycle wiring.
Tests
- 5 unit tests in test/services/ios_audio_session_controller_test.dart
cover activate/deactivate on iOS, no-op on non-iOS, and graceful
PlatformException handling.
Docs
- SRS SRS-110 expanded to cover the call-scoped lifecycle invariant.
- SysDes mobile-voice row updated to reflect the MethodChannel and
.ambient idle baseline.
- implementation-status-2026-05-28 voiceChat row flipped to done.
Verification
- flutter analyze: No issues found (2.8s)
- flutter test: 195 passed / 2 skipped / 0 failed
- cargo build -p chanora_audio --target aarch64-apple-ios: clean
- cargo build -p chanora_audio (macOS host): clean
Device QA matrix (Spotify-keeps-playing-while-idle, mix-during-call,
revert-on-call-end, media-services-reset-during-idle) remains pending
on physical hardware.
apps/chanora_flutter/macos/Runner/MacOSAudioLifecycle.swift (new): native MethodChannel handler for chanora/macos_audio_lifecycle. Observes Core Audio HAL default-input and default-output device property changes via AudioObjectAddPropertyListener; posts handleDefaultDeviceChange events with {role: input|output} payload. Mirrors the iOS chanora/ios_audio_lifecycle event surface minus the AVAudioSession-specific events (no interruption / no media services reset equivalents on macOS — no AVAudioSession).
apps/chanora_flutter/macos/Runner/MainFlutterWindow.swift: register MacOSAudioLifecycle next to MacOSPermissionsHandler in awakeFromNib. Closes the iOS/macOS asymmetry noted in SysRS-051.
apps/chanora_flutter/lib/services/audio_lifecycle_service.dart: add wireMacosAudioLifecycle() parallel to wireIosAudioLifecycle() / wireAndroidAudioLifecycle(). The current implementation captures and logs the events; the FRB function that triggers a VPIO re-bind on the engine is a follow-up. Event-shape mirrors the iOS side so a future caller can switch on platform without changing the dispatch shape.
apps/chanora_flutter/test/services/audio_lifecycle_service_test.dart: smoke test for wireMacosAudioLifecycle (4 tests pass, including the new one).
- Replace broad POSIX error checks (EACCES/EPERM/ENETDOWN) with the
canonical kDNSServiceErr_PolicyDenied DNS error in the NWBrowser
state handler, matching the pattern used by Expo, Pulse, Strongbox,
and WLED. Detect denial in both .failed and .waiting states.
- Add checkLocalNetworkAccess(host:port:) — a read-only NWConnection
probe (Sequel-Ace pattern) that checks
NWPath.unsatisfiedReason == .localNetworkDenied without triggering
a new system prompt. Useful for confirming denial against a specific
destination before attempting to connect.
- In _onConnect, after the prompt resolves to Denied, confirm with
checkLocalNetworkAccess against the target host. If confirmed,
abort the connect attempt and show a non-modal snackbar with an
'Open System Settings' action that deep-links to
Privacy_LocalNetwork. Previously the app would proceed to connect,
fail with PermissionDenied, and surface a redundant in-app modal.
- Drop the now-orphaned _openIosAppSettings helper and
_iosPlatformChannel constant (the only caller was the removed
in-app permission dialog).
- Add unit tests for checkLocalNetworkAccess covering outbound
MethodCall arguments and state parsing for Granted/Denied.
Trace: SRS-300.
Oracle re-review pass on PR #28 flagged that the doc comment named
IOSAudioLifecycleController.classifyDevice, but no such class exists
in the repo. The actual iOS classifier is
AppDelegate.classifyAudioRoute(_:) in ios/Runner/AppDelegate.swift
(line 292), invoked from the route-change and media-services-reset
handlers (lines 189, 237).
Android side is correct: AndroidAudioLifecycleController.classifyDevice
exists at android/app/src/main/kotlin/app/chanora/chanora_flutter/
AndroidAudioLifecycleController.kt.
Adds a doc comment to parseBridgeAudioRoute clarifying that both
iOS and Android producers (IOSAudioLifecycleController.classifyDevice
and AndroidAudioLifecycleController.classifyDevice) emit exact
PascalCase strings.
Case variants (USB_HEADSET, usb_headset, UsbHeadphone) fall through
to unknown by design. This is a silent failure mode worth documenting
so future changes to either platform classifier are paired with a
parser update.
Per PR #28 review feedback.
apps/chanora_flutter/lib/services/audio_lifecycle_service.dart: extend parseBridgeAudioRoute to handle 'UsbHeadset' (maps to wiredHeadset — USB audio is functionally a wired-class device, matching AndroidAudioLifecycleController.classifyCurrentRoute's own preference ordering at line 176) and 'Hdmi' (maps to unknown — HDMI is a display-out transport, not a voice-call audio path; no existing BridgeAudioRoute variant fits; safer to leave as unknown than to misclassify as Speaker). Previously these Android-emitted strings hit the default branch and silently became BridgeAudioRoute.unknown.
apps/chanora_flutter/test/services/audio_lifecycle_service_test.dart: split the existing single test into three — iOS-classified strings (preserved), Android UsbHeadset (new), Android Hdmi (new). flutter test: 3 passed, 0 failed.
* feat(macos): add macOS permissions service for Input Monitoring, Local Network, and Notifications
Add MacOSPermissionsService (Dart) + native MethodChannel handler (Swift)
for macOS-specific permissions not covered by permission_handler:
- Input Monitoring (CGPreflightListenEventAccess /
CGRequestListenEventAccess) for global PTT via Event Tap
- Local Network Privacy prompt (NWBrowser for _ts3._tcp, macOS 15+)
- Notifications (UNUserNotificationCenter authorization)
Trace: SRS-198, SRS-297, SRS-300, SysRS-166, SDD-091
Changes:
- Info.plist: add NSBonjourServices array with _ts3._tcp
- macos_permissions_service.dart: Dart service with MethodChannel,
ValueNotifier states, PTT capability derivation (L0Focused /
L1MacOSEventTap), non-macOS short-circuit
- MainFlutterWindow.swift: native handler registered as FlutterPlugin,
Input Monitoring check/request/polling, NWBrowser trigger with
denial detection, UNUserNotificationCenter request
- main.dart: wire service into bootstrap lifecycle, listen for PTT
capability changes from Input Monitoring state
- macos_permissions_service_test.dart: 17 unit tests covering inbound
state changes, outbound calls, lifecycle, error handling, platform
behavior (179/179 full suite pass)
* fix(macos): keep permissions capability state live
Clarify ONNX Runtime guidance with direct-open install hints, restore desktop WebRTC VAD visibility, map mouse side buttons through focused PTT capture/runtime paths, and wait for server acks before showing chat sends as successful.
Constraint: Linux release UX must stay functional when ONNX Runtime is optional and GNOME portal availability varies
Rejected: Keep desktop VAD locked to Silero only | misleads users when ONNX Runtime is skipped
Confidence: medium
Scope-risk: moderate
Directive: Preserve the protocol send-ack wait path for chat so UI success always tracks real server acceptance
Tested: flutter analyze lib/main.dart lib/widgets/chat_views.dart lib/widgets/input_dialogs.dart lib/widgets/startup_dependency_screen.dart; flutter test test/widgets/input_dialogs_test.dart test/widgets/chat_views_test.dart test/services/startup_dependency_check_test.dart test/widgets/startup_dependency_screen_test.dart test/widgets/voice_settings_controls_test.dart test/widgets/audio_processing_config_state_test.dart; cargo test -p chanora_protocol --lib; cargo test -p chanora_audio ptt_backends --lib
Not-tested: Live manual GNOME portal rebind/global PTT on a real desktop session; observer-bot chat against a live server after the sender-name fallback change
Dart consumer for the Android permission state pipeline. New
AndroidPermissionsService listens on the app.chanora/android_permissions
MethodChannel and exposes a ValueListenable for the UI. The voice-join
flow in main.dart calls ensureRecordAudio() before rust.voiceJoin and
clamps to listen-only via setHardMute on denial. A non-modal banner
above the VoiceBar surfaces the Grant / Open Settings action depending
on whether the state is Denied or PermanentlyDenied. On non-Android
hosts the service short-circuits to granted; the banner is never built.
Also adds the BackIntentService Dart consumer (back_intent_policy +
back_intent_service) which the Kotlin BackIntentBridge invokes via
MethodChannel for deterministic route-pop ordering.
Trace: SDD-028, SDD-106, SRS-163, SRS-209.