mattermost-mobile-upstream/libraries/@mattermost/calls-native/ios/Source/Managers/AudioSessionManager.swift
2026-09-03 19:44:10 +00:00

314 lines
14 KiB
Swift

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import AVFoundation
import Foundation
import Gekidou
import WebRTC
/// Owns the `AVAudioSession` configuration for a Calls voice session, and
/// bridges CallKit's `didActivate` / `didDeactivate` callbacks into
/// react-native-webrtc's `RTCAudioSession` singleton — which is the
/// integration point officially documented by react-native-webrtc.
///
/// All AVAudioSession mutations go through `RTCAudioSession.sharedInstance()`
/// (WebRTC's proxy) via `withConfigurationLock`; only read-only route inspection
/// touches AVAudioSession directly.
@objc public final class AudioSessionManager: NSObject {
private var rtcSession: RTCAudioSession { RTCAudioSession.sharedInstance() }
private var avSession: AVAudioSession { AVAudioSession.sharedInstance() }
private var ringtonePlayer: AVAudioPlayer?
weak var bridge: CallsBridge?
init(bridge: CallsBridge) {
super.init()
self.bridge = bridge
NotificationCenter.default.addObserver(
self,
selector: #selector(routeChanged(_:)),
name: AVAudioSession.routeChangeNotification,
object: nil
)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: - Configuration lock
/// Runs `body` holding WebRTC's configuration lock, which every `RTCAudioSession`
/// proxy setter requires: they open with a `checkLock:` and fail with
/// `kRTCAudioSessionErrorLockRequired` ("Must call lockForConfiguration before
/// calling this method") when it isn't held. `setCategory` and `setActive` are the
/// two we use.
private func withConfigurationLock<T>(_ body: () throws -> T) rethrows -> T {
rtcSession.lockForConfiguration()
defer { rtcSession.unlockForConfiguration() }
return try body()
}
// MARK: - Call session configuration
/// Apply the category + mode + options for a Calls voice session.
/// Called from `CXAnswerCallAction` (incoming) and `CXStartCallAction` (outgoing).
/// Also overwrites WebRTC's stored RTCAudioSessionConfiguration so that
/// any internal reconfiguration WebRTC performs (e.g. on audioSessionDidActivate)
/// uses the same options we set — specifically .allowBluetoothHFP — rather
/// than its own defaults which don't include Bluetooth options.
@objc public func configureForCall() {
try? configureForCallThrowing()
}
/// Called from JS `startAudioSession()`. On the normal path CallKit already
/// called `configureForCall()` via `CXStartCallAction` / `CXAnswerCallAction`,
/// so this is a no-op. When CallKit registration failed the session is still
/// unconfigured, so we configure it here as a fallback and surface any error.
@objc(startAudioSessionWithError:)
public func startAudioSession() throws {
guard avSession.category != .playAndRecord else { return }
try configureForCallThrowing()
rtcSession.isAudioEnabled = true
}
private func configureForCallThrowing() throws {
let webRTCConfig = RTCAudioSessionConfiguration()
webRTCConfig.category = AVAudioSession.Category.playAndRecord.rawValue
webRTCConfig.categoryOptions = [.allowBluetoothHFP, .allowBluetoothA2DP, .duckOthers]
webRTCConfig.mode = AVAudioSession.Mode.voiceChat.rawValue
RTCAudioSessionConfiguration.setWebRTC(webRTCConfig)
do {
try withConfigurationLock {
try rtcSession.setCategory(
.playAndRecord,
mode: .voiceChat,
options: [.allowBluetoothHFP, .allowBluetoothA2DP, .duckOthers]
)
}
GekidouLogger.shared.log(.info,
"AudioSessionManager: configured category=playAndRecord mode=voiceChat")
} catch {
GekidouLogger.shared.log(.error,
"AudioSessionManager: setCategory failed: \(error.localizedDescription)")
throw error
}
}
/// Called from JS `stopAudioSession` when the RTC connection closes.
/// For CallKit-backed calls this runs before `provider(_:didDeactivate:)` arrives —
/// the early `setActive(false)` is redundant but harmless: RTCAudioSession
/// tolerates a deactivation call on an already-inactive session. When CallKit
/// registration failed and `didDeactivate` will never fire, this is the only
/// teardown path.
@objc public func resetSession() {
do {
try withConfigurationLock {
// `isAudioEnabled` doesn't need the lock, but pairing it with the deactivation
// in one locked region keeps CallKit's concurrent `deactivated(_:)` from
// interleaving between the two.
rtcSession.isAudioEnabled = false
try rtcSession.setActive(false)
}
GekidouLogger.shared.log(.info, "AudioSessionManager: resetSession — session deactivated")
} catch {
GekidouLogger.shared.log(.error,
"AudioSessionManager: resetSession failed: \(error.localizedDescription)")
}
}
// MARK: - Audio route selection
/// Select the output audio route. All mutations go through
/// `RTCAudioSession` with the configuration lock held.
/// - `SPEAKER_PHONE`: overrides output to built-in speaker.
/// - `EARPIECE`: clears override; prefers built-in mic input so the
/// system routes output to the receiver.
/// - `BLUETOOTH`: clears override; sets preferred output to the first
/// connected BT HFP/A2DP port.
/// - `WIRED_HEADSET`: clears override; sets preferred output to the
/// connected headphones/headset port.
@objc public func setAudioRoute(_ route: String) {
// overrideOutputAudioPort and setPreferredInput/setPreferredOutput are
// output-routing calls that don't affect WebRTC's internal audio
// configuration, so we call them directly on AVAudioSession rather than
// going through RTCAudioSession. Using the RTCAudioSession proxy for
// these caused spurious route-change notifications that fought with
// CallKit's own speaker-button handling.
do {
switch route {
case "SPEAKER_PHONE":
// Clear any preferred input so earpiece doesn't fight speaker.
try avSession.setPreferredInput(nil)
try avSession.overrideOutputAudioPort(.speaker)
case "EARPIECE":
try avSession.overrideOutputAudioPort(.none)
if let builtInMic = avSession.availableInputs?.first(where: { $0.portType == .builtInMic }) {
try avSession.setPreferredInput(builtInMic)
}
case "BLUETOOTH":
// Must clear speaker override AND preferred input before
// selecting BT — otherwise the earpiece preference set by a
// previous CallKit or in-app route change wins and iOS silently
// ignores the BT port selection.
try avSession.overrideOutputAudioPort(.none)
try avSession.setPreferredInput(nil)
// Prefer BT HFP input so the system commits output to the BT
// device rather than falling back to the receiver.
if let btInput = avSession.availableInputs?.first(where: {
$0.portType == .bluetoothHFP || $0.portType == .bluetoothA2DP
}) {
try avSession.setPreferredInput(btInput)
}
case "WIRED_HEADSET":
try avSession.overrideOutputAudioPort(.none)
try avSession.setPreferredInput(nil)
if let headsetInput = avSession.availableInputs?.first(where: {
$0.portType == .headsetMic
}) {
try avSession.setPreferredInput(headsetInput)
}
default:
try avSession.overrideOutputAudioPort(.none)
try avSession.setPreferredInput(nil)
}
GekidouLogger.shared.log(.info, "AudioSessionManager: setAudioRoute route=\(route)")
} catch {
GekidouLogger.shared.log(.error,
"AudioSessionManager: setAudioRoute(\(route)) failed: \(error.localizedDescription)")
}
}
/// Returns the currently active output route and the full list of
/// available audio output devices.
@objc public func currentAudioRoute() -> [String: Any] {
let outputs = avSession.currentRoute.outputs
var selected = "EARPIECE"
for port in outputs {
switch port.portType {
case .builtInSpeaker:
selected = "SPEAKER_PHONE"
case .bluetoothHFP, .bluetoothA2DP:
selected = "BLUETOOTH"
case .headphones, .headsetMic:
selected = "WIRED_HEADSET"
default:
break
}
}
let inputs = avSession.availableInputs ?? []
var available: Set<String> = [selected, "SPEAKER_PHONE"]
let hasReceiver = inputs.contains(where: { $0.portType == .builtInMic })
if hasReceiver { available.insert("EARPIECE") }
// Bluetooth HFP: check both active outputs AND available inputs so
// that AirPods/BT headsets remain in the list when speaker or earpiece
// is the current active route (they're still connected, just not active).
let hasBT = outputs.contains(where: { $0.portType == .bluetoothHFP || $0.portType == .bluetoothA2DP })
|| inputs.contains(where: { $0.portType == .bluetoothHFP || $0.portType == .bluetoothA2DP })
if hasBT { available.insert("BLUETOOTH") }
// Wired headset: check outputs (headphones port) and inputs (headsetMic).
let hasWired = outputs.contains(where: { $0.portType == .headphones || $0.portType == .headsetMic })
|| inputs.contains(where: { $0.portType == .headsetMic })
if hasWired { available.insert("WIRED_HEADSET") }
// Use a fixed order so the list is deterministic across emissions with
// identical membership — prevents spurious JS diffs on the available list.
let ordered: [String] = ["BLUETOOTH", "WIRED_HEADSET", "EARPIECE", "SPEAKER_PHONE"]
.filter { available.contains($0) }
return [
"selectedAudioDevice": selected,
"availableAudioDeviceList": ordered,
]
}
// MARK: - Route change observer
@objc private func routeChanged(_ notification: Notification) {
bridge?.send(event: .AudioRouteChanged, body: currentAudioRoute())
}
// MARK: - CallKit lifecycle forwarding
/// CallKit just activated the audio session. Forward to
/// `RTCAudioSession` so react-native-webrtc's internal state matches
/// reality and the audio unit can start when peer-connection audio is
/// ready. Per react-native-webrtc's iOS docs:
/// "your CXProviderDelegate should call through to
/// RTCAudioSession.sharedInstance.audioSessionDidActivate accordingly."
///
/// No configuration lock here or in `deactivated(_:)`: neither
/// `audioSessionDidActivate`/`Deactivate` nor `isAudioEnabled` is a
/// lock-required proxy, and CallKit calls these on its own thread.
@objc func activated(_ audioSession: AVAudioSession) {
rtcSession.audioSessionDidActivate(audioSession)
// Allow WebRTC to initialize its audio unit now that CallKit has
// handed over the session. Must come after audioSessionDidActivate
// so WebRTC's internal state is consistent before the unit starts.
rtcSession.isAudioEnabled = true
GekidouLogger.shared.log(.info, "AudioSessionManager: forwarded didActivate to RTCAudioSession")
}
/// Symmetric for deactivation.
@objc func deactivated(_ audioSession: AVAudioSession) {
// Stop WebRTC's audio unit before handing the session back to CallKit.
rtcSession.isAudioEnabled = false
rtcSession.audioSessionDidDeactivate(audioSession)
GekidouLogger.shared.log(.info, "AudioSessionManager: forwarded didDeactivate to RTCAudioSession")
}
// MARK: - Ringtone
/// Play a ringtone from the app bundle. File is resolved by name (e.g. `<name>.mp3`)
/// with a fallback to `default_ringtone.mp3`.
@objc public func startRingtone(_ name: String) throws {
stopRingtone()
let url: URL
if let bundleURL = Bundle.main.url(forResource: name, withExtension: "mp3") {
url = bundleURL
} else if let defaultURL = Bundle.main.url(forResource: "default_ringtone", withExtension: "mp3") {
url = defaultURL
} else {
throw NSError(
domain: "CallsNative",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Ringtone not found: \(name)"]
)
}
// Don't overwrite the call session category if configureForCall() has
// already run — WebRTC's audio unit must keep its playAndRecord session.
let callSessionActive = avSession.category == .playAndRecord
if !callSessionActive {
try avSession.setCategory(.soloAmbient)
try avSession.setActive(true)
}
ringtonePlayer = try AVAudioPlayer(contentsOf: url)
ringtonePlayer?.numberOfLoops = -1
ringtonePlayer?.play()
GekidouLogger.shared.log(.info, "AudioSessionManager: startRingtone name=\(name)")
}
@objc public func stopRingtone() {
guard ringtonePlayer != nil else { return }
ringtonePlayer?.stop()
ringtonePlayer = nil
if avSession.category != .playAndRecord {
try? avSession.setActive(false, options: .notifyOthersOnDeactivation)
}
GekidouLogger.shared.log(.info, "AudioSessionManager: stopRingtone")
}
}