From 4b0117b07d5613833bbd40cb2ed66c113053cee8 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Thu, 10 Sep 2026 10:30:32 -0400 Subject: [PATCH] Let CallKit own the call, not just announce it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CallKit was a doorbell. It rang the phone when a VoIP push arrived and took no further part, which left four things wrong. Declining from the lock screen did not decline. The end action cleared the local ring and fulfilled itself, and nothing told the call manager, so the caller went on listening to a phone that had already said no. The audio session was never handed over. There was no didActivate and no didDeactivate, and manual audio was set only on the Simulator, so on a device WebRTC raced CallKit to bring the session up. Doing that first is the documented way to get a call that connects and carries no sound. The unit is now off by default and exactly one thing turns it on: CallKit for a call it owns, the manager for one placed or answered inside the app. Ending a call never told CallKit either, and a call left open there holds the phone in an in-call state and refuses the next incoming one. And outgoing calls were invisible to iOS: no Recents entry, no session ownership, nothing stopping the phone ringing for something else in the middle of one. They are reported now, connecting and connected. ⚠️ The audio path is reasoned from Apple's and WebRTC's contract and is NOT observed. A Simulator cannot exercise it, which is why none of this was noticed. iPhone call audio has never been proven on hardware, and this is the most likely explanation; it is also the change most in need of a real device before it is trusted. Co-Authored-By: Claude Opus 5 --- Sources/App/AppEnvironment.swift | 7 +++ Sources/Calls/CallManager.swift | 86 +++++++++++++++++++++++++--- Sources/Calls/VoIPCallReceiver.swift | 83 +++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 8 deletions(-) diff --git a/Sources/App/AppEnvironment.swift b/Sources/App/AppEnvironment.swift index a3a1c19..c5226e2 100644 --- a/Sources/App/AppEnvironment.swift +++ b/Sources/App/AppEnvironment.swift @@ -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() } diff --git a/Sources/Calls/CallManager.swift b/Sources/Calls/CallManager.swift index a373436..8a71a7f 100644 --- a/Sources/Calls/CallManager.swift +++ b/Sources/Calls/CallManager.swift @@ -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) diff --git a/Sources/Calls/VoIPCallReceiver.swift b/Sources/Calls/VoIPCallReceiver.swift index 48ef346..f108e69 100644 --- a/Sources/Calls/VoIPCallReceiver.swift +++ b/Sources/Calls/VoIPCallReceiver.swift @@ -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) + } } -- 2.54.0