Files
flamenet-messenger-ios/Sources/Calls/VoIPCallReceiver.swift
T
Paul HittandClaude Opus 5 4b0117b07d Let CallKit own the call, not just announce it
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 <noreply@anthropic.com>
2026-09-10 10:30:32 -04:00

261 lines
9.5 KiB
Swift

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
final class VoIPCallReceiver: NSObject {
static let shared = VoIPCallReceiver()
/// Hands a freshly issued VoIP token to the relay.
///
/// Setting it flushes anything PushKit already handed over. PushKit issues
/// its token as soon as the registry exists, which is before a session
/// does, so on a fresh install the token arrives FIRST and dropping it
/// left a phone that could not be rung.
var uploadToken: ((String) async throws -> Void)? {
didSet { flush() }
}
/// Issued but not yet accepted by the relay.
private var pending: String?
/// Asks the app to fetch and open whatever is waiting, once a call has been
/// 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.
private(set) var currentToken: String?
/// 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
config.maximumCallsPerCallGroup = 1
// Generic on purpose.
config.supportedHandleTypes = [.generic]
provider = CXProvider(configuration: config)
super.init()
provider.setDelegate(self, queue: nil)
}
/// Start receiving VoIP pushes.
func start() {
guard registry == nil else { return }
let r = PKPushRegistry(queue: .main)
r.delegate = self
r.desiredPushTypes = [.voIP]
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 }
let update = CXCallUpdate()
update.localizedCallerName = name
provider.reportCall(with: id, updated: update)
}
/// The call ended, was answered elsewhere, or never connected.
func finish(reason: CXCallEndedReason = .remoteEnded) {
guard let id = currentCallID else { return }
currentCallID = nil
provider.reportCall(with: id, endedAt: Date(), reason: reason)
}
/// Report a call to CallKit right now.
private func reportIncoming(completion: @escaping () -> Void) {
let id = UUID()
currentCallID = id
let update = CXCallUpdate()
update.hasVideo = false
update.localizedCallerName = "Flamenet call"
update.remoteHandle = CXHandle(type: .generic, value: "Flamenet")
provider.reportNewIncomingCall(with: id, update: update) { [weak self] error in
if error != nil {
Task { @MainActor in self?.currentCallID = nil }
}
completion()
}
}
}
extension VoIPCallReceiver: PKPushRegistryDelegate {
nonisolated func pushRegistry(
_ registry: PKPushRegistry,
didUpdate credentials: PKPushCredentials,
for type: PKPushType
) {
let hex = credentials.token.map { String(format: "%02x", $0) }.joined()
Task { @MainActor in
// Skip an unchanged token, like the alert registrar does: PushKit
// reissues for its own reasons and the relay rate-limits writes.
self.currentToken = hex
guard hex != self.lastUploaded else { return }
self.pending = hex
self.flush()
}
}
/// Send whatever is waiting, if there is anywhere to send it yet.
///
/// Cleared only on success, so a failed upload is retried rather than
/// swallowed: a VoIP token that never lands is a phone that never rings,
/// and nothing else in the app would notice.
private func flush() {
guard let hex = pending, let upload = uploadToken else { return }
Task { @MainActor in
do {
try await upload(hex)
self.lastUploaded = hex
self.pending = nil
} catch {
// Left pending on purpose.
}
}
}
nonisolated func pushRegistry(
_ registry: PKPushRegistry,
didInvalidatePushTokenFor type: PKPushType
) {
Task { @MainActor in
self.lastUploaded = nil
self.pending = nil
self.currentToken = nil
// Clearing it at the relay is the caller's job; leaving a dead
// token there only means a push that goes nowhere.
}
}
nonisolated func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
Task { @MainActor in
// Report FIRST, ask questions later. iOS kills the app if this
// handler returns without a reported call, and the questions, which
// envelope, from whom, need a network round trip and a decrypt that
// this handler has no time for.
self.reportIncoming {
completion()
Task { await self.wake?() }
}
}
}
}
extension VoIPCallReceiver: CXProviderDelegate {
nonisolated func providerDidReset(_ provider: CXProvider) {
Task { @MainActor in self.currentCallID = nil }
}
/// 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()
await self.wake?()
}
}
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)
}
}