Let CallKit own the call, not just announce it #262
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user