diff --git a/ios/Classes/AudioInterruptionHandler.swift b/ios/Classes/AudioInterruptionHandler.swift new file mode 100644 index 000000000..a855f98cd --- /dev/null +++ b/ios/Classes/AudioInterruptionHandler.swift @@ -0,0 +1,142 @@ +/* + * Copyright 2025 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#if os(iOS) +import AVFoundation +import Flutter +import UIKit +import WebRTC + +/// Handles iOS audio session interruptions caused by phone calls and other audio-stealing +/// events, and notifies Flutter when the session is interrupted and when it recovers. +/// +/// iOS 16+ no longer reliably fires `AVAudioSessionInterruptionTypeEnded` for cellular +/// calls. Three notification sources are combined so that whichever fires first after +/// the call ends will attempt session recovery: +/// +/// 1. `AVAudioSession.interruptionNotification` — works for non-call interruptions and +/// some call scenarios on older OS versions. +/// 2. `UIApplication.didBecomeActiveNotification` — covers the common case where the +/// system phone UI pushed the app to the background. +/// 3. `AVAudioSession.routeChangeNotification` — covers Dynamic Island calls where the +/// app stays in the foreground and never backgrounds, so (2) never fires. +/// +/// `isInterrupted` is only cleared when `RTCAudioSession.setActive(true)` succeeds. +/// This means every failed attempt (call still active) silently retries on the next +/// notification rather than resetting the flag prematurely. +@available(iOS 13.0, *) +final class AudioInterruptionHandler: NSObject { + private weak var channel: FlutterMethodChannel? + private var isInterrupted = false // always accessed on main thread + + init(channel: FlutterMethodChannel) { + self.channel = channel + super.init() + registerObservers() + } + + deinit { + unregisterObservers() + } + + // MARK: - Observers + + private func registerObservers() { + let audioSession = AVAudioSession.sharedInstance() + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleInterruption(_:)), + name: AVAudioSession.interruptionNotification, + object: audioSession + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleRouteChange(_:)), + name: AVAudioSession.routeChangeNotification, + object: audioSession + ) + } + + private func unregisterObservers() { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Notification handlers + + @objc private func handleInterruption(_ notification: Notification) { + guard + let info = notification.userInfo, + let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) + else { return } + + if type == .began { + isInterrupted = true + channel?.invokeMethod("audioInterruptionBegan", arguments: nil) + return + } + + guard type == .ended else { return } + // Honour the system's shouldResume hint when present. + if let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt { + guard AVAudioSession.InterruptionOptions(rawValue: optionsValue).contains(.shouldResume) + else { return } + } + recoverAudioSession() + } + + @objc private func handleDidBecomeActive() { + guard isInterrupted else { return } + recoverAudioSession() + } + + @objc private func handleRouteChange(_ notification: Notification) { + guard isInterrupted else { return } + guard notification.userInfo?[AVAudioSessionRouteChangeReasonKey] is UInt else { return } + recoverAudioSession() + } + + // MARK: - Recovery + + private func recoverAudioSession() { + guard isInterrupted else { return } + // Apple recommends a brief pause before reactivating. Without it, + // setActive can fail even after the interruption genuinely ended. + // isInterrupted is NOT cleared here — only on confirmed activation — + // so if the call is still active the next notification retries. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + guard let self, self.isInterrupted else { return } + let rtcSession = RTCAudioSession.sharedInstance() + rtcSession.lockForConfiguration() + defer { rtcSession.unlockForConfiguration() } + do { + try rtcSession.setActive(true) + self.isInterrupted = false + self.channel?.invokeMethod("audioInterruptionEnded", arguments: nil) + } catch { + // Call still active — isInterrupted stays true, next notification retries. + } + } + } +} +#endif diff --git a/lib/src/core/room.dart b/lib/src/core/room.dart index f274a0289..25932144c 100644 --- a/lib/src/core/room.dart +++ b/lib/src/core/room.dart @@ -39,6 +39,7 @@ import '../preconnect/pre_connect_audio_buffer.dart'; import '../proto/livekit_models.pb.dart' as lk_models; import '../proto/livekit_rtc.pb.dart' as lk_rtc; import '../support/disposable.dart'; +import '../support/native.dart'; import '../support/platform.dart'; import '../support/region_url_provider.dart'; import '../support/websocket.dart' show WebSocketException; @@ -116,6 +117,8 @@ class Room extends DisposableChangeNotifier with EventsEmittable { // late EventsListener _signalListener; + StreamSubscription? _audioInterruptionSub; + RegionUrlProvider? _regionUrlProvider; String? _regionUrl; @@ -185,6 +188,10 @@ class Room extends DisposableChangeNotifier with EventsEmittable { preConnectAudioBuffer = PreConnectAudioBuffer(this); + if (lkPlatformIs(PlatformType.iOS)) { + _audioInterruptionSub = Native.audioInterruptionStream.listen(_onAudioInterruption); + } + onDispose(() async { // clean up routine await _cleanUp(); @@ -194,6 +201,8 @@ class Room extends DisposableChangeNotifier with EventsEmittable { await events.dispose(); // dispose local participant await localParticipant?.dispose(); + // cancel iOS audio interruption subscription + await _audioInterruptionSub?.cancel(); // dispose all listeners for SignalClient await _signalListener.dispose(); // dispose all listeners for Engine @@ -1047,6 +1056,11 @@ extension RoomPrivateMethods on Room { } } + void _onAudioInterruption(bool isInterrupted) { + if (connectionState != ConnectionState.connected) return; + events.emit(isInterrupted ? const AudioSessionInterruptedEvent() : const AudioSessionResumedEvent()); + } + /// server assigned unique room id. /// returns once a sid has been issued by the server. Future getSid() async { diff --git a/lib/src/events.dart b/lib/src/events.dart index 816be2bd6..e1f50c84d 100644 --- a/lib/src/events.dart +++ b/lib/src/events.dart @@ -661,3 +661,32 @@ class RoomMovedEvent with RoomEvent { @override String toString() => '${runtimeType}(roomName: $roomName)'; } + +/// iOS only: fired when a phone call or other audio interruption begins. +/// +/// The local audio track remains published but WebRTC has stopped capturing — +/// [LocalParticipant.isMicrophoneEnabled] will still return `true` even though +/// no audio is being transmitted. Apps should mute the local participant to avoid +/// a silent-but-green mic indicator for remote participants. +/// +/// Emitted by [Room]. +class AudioSessionInterruptedEvent with RoomEvent { + const AudioSessionInterruptedEvent(); + + @override + String toString() => '$runtimeType()'; +} + +/// iOS only: fired when [AVAudioSession] has been successfully reactivated +/// after an interruption ended. +/// +/// The local microphone can now be re-enabled. Whether to do so automatically or +/// to let the user decide is left to the application. +/// +/// Emitted by [Room]. +class AudioSessionResumedEvent with RoomEvent { + const AudioSessionResumedEvent(); + + @override + String toString() => '$runtimeType()'; +} diff --git a/lib/src/support/native.dart b/lib/src/support/native.dart index 2f3b3e24d..5c28d949a 100644 --- a/lib/src/support/native.dart +++ b/lib/src/support/native.dart @@ -27,6 +27,12 @@ class Native { @internal static final channel = _createChannel(); + static final _audioInterruptionController = StreamController.broadcast(); + + /// Emits `true` when an iOS audio interruption begins (e.g. phone call), + /// `false` when the session is successfully recovered. + static Stream get audioInterruptionStream => _audioInterruptionController.stream; + static MethodChannel _createChannel() { final channel = MethodChannel('livekit_client'); channel.setMethodCallHandler(_handleMethodCall); @@ -153,6 +159,12 @@ class Native { } _broadcastStateChanged(call.arguments as bool); return null; + case 'audioInterruptionBegan': + _audioInterruptionController.add(true); + return null; + case 'audioInterruptionEnded': + _audioInterruptionController.add(false); + return null; default: logger.warning('Method ${call.method} is not implemented.'); return null; diff --git a/shared_swift/LiveKitPlugin.swift b/shared_swift/LiveKitPlugin.swift index a2d8d8639..a19605510 100644 --- a/shared_swift/LiveKitPlugin.swift +++ b/shared_swift/LiveKitPlugin.swift @@ -54,6 +54,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { #if os(iOS) var cancellable = Set() + var audioInterruptionHandler: AudioInterruptionHandler? #endif public static func register(with registrar: FlutterPluginRegistrar) { @@ -74,6 +75,8 @@ public class LiveKitPlugin: NSObject, FlutterPlugin { channel.invokeMethod("broadcastStateChanged", arguments: isBroadcasting) } .store(in: &instance.cancellable) + + instance.audioInterruptionHandler = AudioInterruptionHandler(channel: channel) #endif }