Merge branch 'feat/ios-callkit-owns-the-call' into 'main'

Let CallKit own the call, not just announce it

See merge request paulhitt/flamenet-messenger-ios!184
This commit was merged in pull request #262.
This commit is contained in:
Paul Hitt
2026-09-10 16:12:06 +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)
}
}
// 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()
}
+78 -8
View File
@@ -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)
+83
View File
@@ -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)
}
}