Let CallKit own the call, not just announce it #262

Merged
paulhitt merged 1 commits from feat/ios-callkit-owns-the-call into main 2026-09-10 16:12:07 +00:00
3 changed files with 168 additions and 8 deletions
+7
View File
@@ -277,6 +277,13 @@ final class AppEnvironment: ObservableObject {
VoIPCallReceiver.shared.identify(caller: name) 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() VoIPCallReceiver.shared.start()
} }
+78 -8
View File
@@ -38,14 +38,22 @@ final class CallManager: NSObject, ObservableObject {
private static let factory: RTCPeerConnectionFactory = { private static let factory: RTCPeerConnectionFactory = {
RTCInitializeSSL() RTCInitializeSSL()
#if targetEnvironment(simulator) // Manual audio everywhere, for two different reasons that want the
// The Simulator's audio stack cannot reliably bring up the voice- // same thing.
// processing audio unit: AudioUnitInitialize hits an RPC timeout //
// inside AudioToolbox, which calls abort(), an incoming call killed // On the Simulator the audio stack cannot reliably bring up the
// the whole app before it even rang. // voice-processing unit: AudioUnitInitialize hits an RPC timeout
RTCAudioSession.sharedInstance().useManualAudio = true // inside AudioToolbox, which calls abort(), so an incoming call killed
RTCAudioSession.sharedInstance().isAudioEnabled = false // the whole app before it even rang.
#endif //
// 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( return RTCPeerConnectionFactory(
encoderFactory: RTCDefaultVideoEncoderFactory(), encoderFactory: RTCDefaultVideoEncoderFactory(),
decoderFactory: RTCDefaultVideoDecoderFactory() decoderFactory: RTCDefaultVideoDecoderFactory()
@@ -90,6 +98,9 @@ final class CallManager: NSObject, ObservableObject {
return return
} }
wasConnected = false 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 wasIncoming = false
guard case .idle = state else { return } guard case .idle = state else { return }
peerID = buddyID peerID = buddyID
@@ -291,6 +302,24 @@ final class CallManager: NSObject, ObservableObject {
finish(reason: "Call ended", notifyPeer: true) 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 // MARK: - In-call controls
func toggleMute() { func toggleMute() {
@@ -507,6 +536,14 @@ final class CallManager: NSObject, ObservableObject {
peerConnection?.close() peerConnection?.close()
peerConnection = nil peerConnection = nil
audioTrack = 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 camera is held by the capturer, not the connection, same rule as
// the mic. // the mic.
videoCapturer?.stopCapture() videoCapturer?.stopCapture()
@@ -551,6 +588,36 @@ final class CallManager: NSObject, ObservableObject {
inbox.onCallSignal = nil inbox.onCallSignal = nil
state = .idle 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) // MARK: - RTCPeerConnectionDelegate (WebRTC thread → main actor)
@@ -577,10 +644,13 @@ extension CallManager: RTCPeerConnectionDelegate {
if case .connecting = self.state { if case .connecting = self.state {
self.state = .active(since: Date()) self.state = .active(since: Date())
self.wasConnected = true self.wasConnected = true
self.enableAudio()
} }
if case .dialing = self.state { if case .dialing = self.state {
self.state = .active(since: Date()) self.state = .active(since: Date())
self.wasConnected = true self.wasConnected = true
self.enableAudio()
VoIPCallReceiver.shared.reportOutgoingConnected()
} }
case .failed: case .failed:
self.finish(reason: "Connection failed", notifyPeer: true) self.finish(reason: "Connection failed", notifyPeer: true)
+83
View File
@@ -1,6 +1,8 @@
import AVFAudio
import CallKit import CallKit
import Foundation import Foundation
import PushKit import PushKit
import WebRTC
/// Rings the phone when a call arrives and the app is not running (§6.14). /// Rings the phone when a call arrives and the app is not running (§6.14).
@MainActor @MainActor
@@ -25,8 +27,17 @@ final class VoIPCallReceiver: NSObject {
/// reported. /// reported.
var wake: (() async -> Void)? 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 var registry: PKPushRegistry?
private let provider: CXProvider private let provider: CXProvider
private let calls = CXCallController()
private var lastUploaded: String? private var lastUploaded: String?
/// The VoIP token this device last had, if any. /// The VoIP token this device last had, if any.
@@ -34,6 +45,15 @@ final class VoIPCallReceiver: NSObject {
/// The call CallKit currently believes is ringing. /// The call CallKit currently believes is ringing.
private var currentCallID: UUID? 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() { private override init() {
let config = CXProviderConfiguration() let config = CXProviderConfiguration()
config.supportsVideo = false config.supportsVideo = false
@@ -54,6 +74,38 @@ final class VoIPCallReceiver: NSObject {
registry = r 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. /// Replace the placeholder name once the offer has been decrypted.
func identify(caller name: String) { func identify(caller name: String) {
guard let id = currentCallID else { return } guard let id = currentCallID else { return }
@@ -161,6 +213,17 @@ extension VoIPCallReceiver: CXProviderDelegate {
} }
/// The user answered from the lock screen. /// 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) { nonisolated func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
Task { @MainActor in Task { @MainActor in
action.fulfill() action.fulfill()
@@ -171,7 +234,27 @@ extension VoIPCallReceiver: CXProviderDelegate {
nonisolated func provider(_ provider: CXProvider, perform action: CXEndCallAction) { nonisolated func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
Task { @MainActor in Task { @MainActor in
self.currentCallID = nil 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() 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)
}
} }