Let CallKit own the call, not just announce it #262
@@ -277,6 +277,13 @@ final class AppEnvironment: ObservableObject {
|
||||
VoIPCallReceiver.shared.identify(caller: name)
|
||||
}
|
||||
}
|
||||
// Refusing from the lock screen has to refuse the call, not just this
|
||||
// phone's ringing. Without this the action was fulfilled, the ring
|
||||
// stopped here, and the caller went on listening to a phone that had
|
||||
// already said no.
|
||||
VoIPCallReceiver.shared.endCall = { [weak self] in
|
||||
self?.callManager?.declineFromSystem()
|
||||
}
|
||||
VoIPCallReceiver.shared.start()
|
||||
}
|
||||
|
||||
|
||||
@@ -38,14 +38,22 @@ final class CallManager: NSObject, ObservableObject {
|
||||
|
||||
private static let factory: RTCPeerConnectionFactory = {
|
||||
RTCInitializeSSL()
|
||||
#if targetEnvironment(simulator)
|
||||
// The Simulator's audio stack cannot reliably bring up the voice-
|
||||
// processing audio unit: AudioUnitInitialize hits an RPC timeout
|
||||
// inside AudioToolbox, which calls abort(), an incoming call killed
|
||||
// the whole app before it even rang.
|
||||
RTCAudioSession.sharedInstance().useManualAudio = true
|
||||
RTCAudioSession.sharedInstance().isAudioEnabled = false
|
||||
#endif
|
||||
// Manual audio everywhere, for two different reasons that want the
|
||||
// same thing.
|
||||
//
|
||||
// On the Simulator the audio stack cannot reliably bring up the
|
||||
// voice-processing unit: AudioUnitInitialize hits an RPC timeout
|
||||
// inside AudioToolbox, which calls abort(), so an incoming call killed
|
||||
// the whole app before it even rang.
|
||||
//
|
||||
// On a device it is CallKit. A call CallKit reported has its session
|
||||
// activated by CallKit, and WebRTC bringing the session up first is
|
||||
// the documented way to get a call that connects and carries no
|
||||
// sound. Leaving the unit off until somebody asks for it means exactly
|
||||
// one thing turns it on: `VoIPCallReceiver` for a call CallKit owns,
|
||||
// `enableAudio` below for one it does not.
|
||||
RTCAudioSession.sharedInstance().useManualAudio = true
|
||||
RTCAudioSession.sharedInstance().isAudioEnabled = false
|
||||
return RTCPeerConnectionFactory(
|
||||
encoderFactory: RTCDefaultVideoEncoderFactory(),
|
||||
decoderFactory: RTCDefaultVideoDecoderFactory()
|
||||
@@ -90,6 +98,9 @@ final class CallManager: NSObject, ObservableObject {
|
||||
return
|
||||
}
|
||||
wasConnected = false
|
||||
// Tell the system before the offer goes out, so the call exists to iOS
|
||||
// for its whole life rather than only once it is answered.
|
||||
VoIPCallReceiver.shared.reportOutgoing(to: name)
|
||||
wasIncoming = false
|
||||
guard case .idle = state else { return }
|
||||
peerID = buddyID
|
||||
@@ -291,6 +302,24 @@ final class CallManager: NSObject, ObservableObject {
|
||||
finish(reason: "Call ended", notifyPeer: true)
|
||||
}
|
||||
|
||||
/// End the call because the system asked, from the lock screen or from
|
||||
/// Recents, rather than from this app's own buttons.
|
||||
///
|
||||
/// Declining and hanging up are the same wire message with different
|
||||
/// reasons, and which one this is depends only on whether it had been
|
||||
/// answered yet. CallKit does not say which button was pressed, and the
|
||||
/// peer deserves the truthful one.
|
||||
func declineFromSystem() {
|
||||
switch state {
|
||||
case .idle, .ended:
|
||||
return
|
||||
case .ringing:
|
||||
finish(reason: "Declined", notifyPeer: true, endReason: "declined")
|
||||
case .dialing, .connecting, .active:
|
||||
finish(reason: "Call ended", notifyPeer: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - In-call controls
|
||||
|
||||
func toggleMute() {
|
||||
@@ -507,6 +536,14 @@ final class CallManager: NSObject, ObservableObject {
|
||||
peerConnection?.close()
|
||||
peerConnection = nil
|
||||
audioTrack = nil
|
||||
// CallKit is told before the audio goes, because an unended call there
|
||||
// leaves the phone in an in-call state and refuses the next incoming
|
||||
// one. `finish` is a no-op when CallKit ended it and told us.
|
||||
VoIPCallReceiver.shared.finish(
|
||||
reason: endReason == "declined" ? .declinedElsewhere : .remoteEnded)
|
||||
// The microphone goes back, or a voice note recorded straight after a
|
||||
// call finds the session still held.
|
||||
disableAudio()
|
||||
// The camera is held by the capturer, not the connection, same rule as
|
||||
// the mic.
|
||||
videoCapturer?.stopCapture()
|
||||
@@ -551,6 +588,36 @@ final class CallManager: NSObject, ObservableObject {
|
||||
inbox.onCallSignal = nil
|
||||
state = .idle
|
||||
}
|
||||
|
||||
// MARK: - Audio
|
||||
|
||||
/// Bring the audio unit up for a call CallKit does not own.
|
||||
///
|
||||
/// The unit is off by default, see the factory above. For a call CallKit
|
||||
/// reported, CallKit activates the session and `VoIPCallReceiver` turns the
|
||||
/// unit on from `didActivate`; doing it here as well would race that, and
|
||||
/// racing it is the documented way to end up with a silent call. For a call
|
||||
/// placed or answered inside the app, CallKit was never told, so nothing
|
||||
/// else is going to.
|
||||
private func enableAudio() {
|
||||
guard !VoIPCallReceiver.shared.ownsAudio else { return }
|
||||
let session = RTCAudioSession.sharedInstance()
|
||||
session.lockForConfiguration()
|
||||
try? session.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth])
|
||||
try? session.setActive(true)
|
||||
session.unlockForConfiguration()
|
||||
session.isAudioEnabled = true
|
||||
}
|
||||
|
||||
/// Put it back, so a voice note or a story can have the microphone.
|
||||
private func disableAudio() {
|
||||
let session = RTCAudioSession.sharedInstance()
|
||||
session.isAudioEnabled = false
|
||||
guard !VoIPCallReceiver.shared.ownsAudio else { return }
|
||||
session.lockForConfiguration()
|
||||
try? session.setActive(false)
|
||||
session.unlockForConfiguration()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - RTCPeerConnectionDelegate (WebRTC thread → main actor)
|
||||
@@ -577,10 +644,13 @@ extension CallManager: RTCPeerConnectionDelegate {
|
||||
if case .connecting = self.state {
|
||||
self.state = .active(since: Date())
|
||||
self.wasConnected = true
|
||||
self.enableAudio()
|
||||
}
|
||||
if case .dialing = self.state {
|
||||
self.state = .active(since: Date())
|
||||
self.wasConnected = true
|
||||
self.enableAudio()
|
||||
VoIPCallReceiver.shared.reportOutgoingConnected()
|
||||
}
|
||||
case .failed:
|
||||
self.finish(reason: "Connection failed", notifyPeer: true)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import AVFAudio
|
||||
import CallKit
|
||||
import Foundation
|
||||
import PushKit
|
||||
import WebRTC
|
||||
|
||||
/// Rings the phone when a call arrives and the app is not running (§6.14).
|
||||
@MainActor
|
||||
@@ -25,8 +27,17 @@ final class VoIPCallReceiver: NSObject {
|
||||
/// reported.
|
||||
var wake: (() async -> Void)?
|
||||
|
||||
/// Hang up for real, when the refusal came from CallKit rather than the
|
||||
/// app's own UI.
|
||||
///
|
||||
/// Declining from the lock screen used to fulfil the action and clear the
|
||||
/// local ring, and nothing else: the caller was left listening to a phone
|
||||
/// that had already said no.
|
||||
var endCall: (() -> Void)?
|
||||
|
||||
private var registry: PKPushRegistry?
|
||||
private let provider: CXProvider
|
||||
private let calls = CXCallController()
|
||||
private var lastUploaded: String?
|
||||
|
||||
/// The VoIP token this device last had, if any.
|
||||
@@ -34,6 +45,15 @@ final class VoIPCallReceiver: NSObject {
|
||||
/// The call CallKit currently believes is ringing.
|
||||
private var currentCallID: UUID?
|
||||
|
||||
/// Whether CallKit owns the call in progress, and therefore its audio.
|
||||
///
|
||||
/// Exactly one thing may bring the audio session up. For a call CallKit
|
||||
/// reported, that is CallKit, and WebRTC doing it first is the documented
|
||||
/// way to get a call that connects in silence. For a call placed or
|
||||
/// answered inside the app, CallKit knows nothing about it and the manager
|
||||
/// has to do it instead.
|
||||
var ownsAudio: Bool { currentCallID != nil }
|
||||
|
||||
private override init() {
|
||||
let config = CXProviderConfiguration()
|
||||
config.supportsVideo = false
|
||||
@@ -54,6 +74,38 @@ final class VoIPCallReceiver: NSObject {
|
||||
registry = r
|
||||
}
|
||||
|
||||
/// Tell the system a call is being placed.
|
||||
///
|
||||
/// Without this an outgoing call is invisible to iOS: it never reaches
|
||||
/// Recents, the phone will happily ring for something else in the middle of
|
||||
/// it, and the session belongs to whoever grabbed it. Incoming calls have
|
||||
/// been reported since VoIP push arrived; this is the other half.
|
||||
func reportOutgoing(to name: String) {
|
||||
guard currentCallID == nil else { return }
|
||||
let id = UUID()
|
||||
let handle = CXHandle(type: .generic, value: name)
|
||||
let start = CXStartCallAction(call: id, handle: handle)
|
||||
start.isVideo = false
|
||||
calls.request(CXTransaction(action: start)) { [weak self] error in
|
||||
Task { @MainActor in
|
||||
guard error == nil else { return }
|
||||
self?.currentCallID = id
|
||||
let update = CXCallUpdate()
|
||||
update.localizedCallerName = name
|
||||
update.remoteHandle = handle
|
||||
update.hasVideo = false
|
||||
self?.provider.reportCall(with: id, updated: update)
|
||||
self?.provider.reportOutgoingCall(with: id, startedConnectingAt: Date())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The far end picked up.
|
||||
func reportOutgoingConnected() {
|
||||
guard let id = currentCallID else { return }
|
||||
provider.reportOutgoingCall(with: id, connectedAt: Date())
|
||||
}
|
||||
|
||||
/// Replace the placeholder name once the offer has been decrypted.
|
||||
func identify(caller name: String) {
|
||||
guard let id = currentCallID else { return }
|
||||
@@ -161,6 +213,17 @@ extension VoIPCallReceiver: CXProviderDelegate {
|
||||
}
|
||||
|
||||
/// The user answered from the lock screen.
|
||||
/// The system asked us to place the call we just told it about. The call is
|
||||
/// already on its way; this is where CallKit expects the audio session to
|
||||
/// be readied, which it then activates for us.
|
||||
nonisolated func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
|
||||
let rtc = RTCAudioSession.sharedInstance()
|
||||
rtc.lockForConfiguration()
|
||||
try? rtc.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth])
|
||||
rtc.unlockForConfiguration()
|
||||
action.fulfill()
|
||||
}
|
||||
|
||||
nonisolated func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
||||
Task { @MainActor in
|
||||
action.fulfill()
|
||||
@@ -171,7 +234,27 @@ extension VoIPCallReceiver: CXProviderDelegate {
|
||||
nonisolated func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
|
||||
Task { @MainActor in
|
||||
self.currentCallID = nil
|
||||
// Actually end it. This used to clear the local ring and stop
|
||||
// there, which silenced this phone and left the caller ringing.
|
||||
self.endCall?()
|
||||
action.fulfill()
|
||||
}
|
||||
}
|
||||
|
||||
/// CallKit owns the audio session, and hands it over here.
|
||||
///
|
||||
/// WebRTC must not bring the session up itself on a CallKit call: the
|
||||
/// documented result is a call that connects and carries no audio. So the
|
||||
/// audio unit stays off until this fires, and goes off again below.
|
||||
nonisolated func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
|
||||
let rtc = RTCAudioSession.sharedInstance()
|
||||
rtc.audioSessionDidActivate(audioSession)
|
||||
rtc.isAudioEnabled = true
|
||||
}
|
||||
|
||||
nonisolated func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
|
||||
let rtc = RTCAudioSession.sharedInstance()
|
||||
rtc.isAudioEnabled = false
|
||||
rtc.audioSessionDidDeactivate(audioSession)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user