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) + } }