Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions ios/Classes/AudioInterruptionHandler.swift
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions lib/src/core/room.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -116,6 +117,8 @@ class Room extends DisposableChangeNotifier with EventsEmittable<RoomEvent> {
//
late EventsListener<SignalEvent> _signalListener;

StreamSubscription<bool>? _audioInterruptionSub;

RegionUrlProvider? _regionUrlProvider;
String? _regionUrl;

Expand Down Expand Up @@ -185,6 +188,10 @@ class Room extends DisposableChangeNotifier with EventsEmittable<RoomEvent> {

preConnectAudioBuffer = PreConnectAudioBuffer(this);

if (lkPlatformIs(PlatformType.iOS)) {
_audioInterruptionSub = Native.audioInterruptionStream.listen(_onAudioInterruption);
}

onDispose(() async {
// clean up routine
await _cleanUp();
Expand All @@ -194,6 +201,8 @@ class Room extends DisposableChangeNotifier with EventsEmittable<RoomEvent> {
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
Expand Down Expand Up @@ -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<String> getSid() async {
Expand Down
29 changes: 29 additions & 0 deletions lib/src/events.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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()';
}
12 changes: 12 additions & 0 deletions lib/src/support/native.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ class Native {
@internal
static final channel = _createChannel();

static final _audioInterruptionController = StreamController<bool>.broadcast();

/// Emits `true` when an iOS audio interruption begins (e.g. phone call),
/// `false` when the session is successfully recovered.
static Stream<bool> get audioInterruptionStream => _audioInterruptionController.stream;

static MethodChannel _createChannel() {
final channel = MethodChannel('livekit_client');
channel.setMethodCallHandler(_handleMethodCall);
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions shared_swift/LiveKitPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {

#if os(iOS)
var cancellable = Set<AnyCancellable>()
var audioInterruptionHandler: AudioInterruptionHandler?
#endif

public static func register(with registrar: FlutterPluginRegistrar) {
Expand All @@ -74,6 +75,8 @@ public class LiveKitPlugin: NSObject, FlutterPlugin {
channel.invokeMethod("broadcastStateChanged", arguments: isBroadcasting)
}
.store(in: &instance.cancellable)

instance.audioInterruptionHandler = AudioInterruptionHandler(channel: channel)
#endif
}

Expand Down
Loading