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>
700 lines
28 KiB
Swift
700 lines
28 KiB
Swift
import AVFoundation
|
|
import Foundation
|
|
import WebRTC
|
|
import os.log
|
|
|
|
/// One end-to-end encrypted voice call (spec §6.8).
|
|
@MainActor
|
|
final class CallManager: NSObject, ObservableObject {
|
|
|
|
enum CallState: Equatable {
|
|
case idle
|
|
case dialing // we sent an offer, waiting for the answer
|
|
case ringing // an offer is waiting for the user's decision
|
|
case connecting // SDP exchanged, ICE still working
|
|
case active(since: Date)
|
|
case ended(String) // brief tombstone before returning to idle
|
|
}
|
|
|
|
@Published private(set) var state: CallState = .idle
|
|
@Published private(set) var peerID: Int = 0
|
|
@Published private(set) var peerName: String = ""
|
|
@Published private(set) var isMuted = false
|
|
@Published private(set) var isSpeakerOn = false
|
|
|
|
// -- Video (§6.8v) ------------------------------------------------------.
|
|
/** This call carries video, set from the offer on both ends. */
|
|
@Published private(set) var isVideo = false
|
|
|
|
/// The contact whose keys need reviewing, when THAT is why the call failed.
|
|
@Published private(set) var identityReviewPeer: Int?
|
|
/** Our camera, for the corner preview. Nil until capture starts. */
|
|
@Published private(set) var localVideoTrack: RTCVideoTrack?
|
|
/** Their camera. Nil until the first remote frame source appears. */
|
|
@Published private(set) var remoteVideoTrack: RTCVideoTrack?
|
|
private var videoCapturer: RTCCameraVideoCapturer?
|
|
|
|
private let inbox: E2EInbox
|
|
|
|
private static let factory: RTCPeerConnectionFactory = {
|
|
RTCInitializeSSL()
|
|
// 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()
|
|
)
|
|
}()
|
|
|
|
/// Whether the call now ending ever reached `.active`.
|
|
private var wasConnected = false
|
|
/// Which way it went, kept because `peerID` alone does not say.
|
|
private var wasIncoming = false
|
|
|
|
private var peerConnection: RTCPeerConnection?
|
|
private var audioTrack: RTCAudioTrack?
|
|
private var callID = ""
|
|
private var pendingOffer: (signal: E2EAttachment.CallSignal, from: Int)?
|
|
/// Calls already over, late signals for these are dropped, not re-rung.
|
|
private var endedCallIDs: Set<String> = []
|
|
private var gatheringWaiter: CheckedContinuation<Void, Never>?
|
|
private var fastPollTask: Task<Void, Never>?
|
|
private var timeoutTask: Task<Void, Never>?
|
|
|
|
/// How long an unanswered outgoing call rings before giving up (§6.8).
|
|
private let dialTimeout: TimeInterval = 45
|
|
|
|
init(inbox: E2EInbox) {
|
|
self.inbox = inbox
|
|
super.init()
|
|
inbox.onCallSignal = { [weak self] signal, from, sentAt in
|
|
self?.handle(signal, from: from, sentAt: sentAt)
|
|
}
|
|
}
|
|
|
|
// MARK: - Outgoing
|
|
|
|
func startCall(to buddyID: Int, name: String, video: Bool = false) {
|
|
identityReviewPeer = nil
|
|
// Supervision: the restriction is enforced where the call would start,
|
|
// not just where the button is drawn, a deep link or a retry path must
|
|
// hit the same wall the UI does.
|
|
if ParentalControls.callsDisabled {
|
|
state = .ended("Calls are turned off on this device")
|
|
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
|
|
peerName = name
|
|
callID = UUID().uuidString.lowercased()
|
|
isVideo = video
|
|
state = .dialing
|
|
startFastPolling()
|
|
armDialTimeout()
|
|
|
|
Task {
|
|
do {
|
|
let pc = try await makePeerConnection(video: video)
|
|
let offer = try await createLocalDescription(pc, offer: true)
|
|
try await inbox.sendCallSignal(
|
|
.init(
|
|
action: .offer, callID: callID, sdp: offer, reason: nil,
|
|
video: video),
|
|
to: buddyID
|
|
)
|
|
} catch {
|
|
os_log(
|
|
"startCall failed: %{public}@",
|
|
log: OSLog(subsystem: "io.flamenet.messenger", category: "calls"),
|
|
type: .error, String(describing: error))
|
|
// Name the trust gate when it is the trust gate.
|
|
if case E2EError.identityChanged = error {
|
|
identityReviewPeer = buddyID
|
|
finish(reason: "Their security code changed", notifyPeer: false)
|
|
} else {
|
|
finish(reason: "Couldn't start the call", notifyPeer: false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Incoming
|
|
|
|
/// Why a call signal went nowhere.
|
|
private func ignored(_ reason: String, _ callID: String) {
|
|
os_log(
|
|
"call signal ignored (%{public}@) call=%{public}@",
|
|
log: OSLog(subsystem: "io.flamenet.messenger", category: "calls"),
|
|
type: .error, reason, String(callID.prefix(8)))
|
|
}
|
|
|
|
private func handle(
|
|
_ signal: E2EAttachment.CallSignal, from userID: Int,
|
|
sentAt: Date
|
|
) {
|
|
os_log(
|
|
"call signal in: %{public}@ from %{public}d",
|
|
log: OSLog(subsystem: "io.flamenet.messenger", category: "calls"),
|
|
type: .info, signal.action.rawValue, userID)
|
|
|
|
guard !endedCallIDs.contains(signal.callID) else {
|
|
return ignored("already ended", signal.callID)
|
|
}
|
|
switch signal.action {
|
|
case .offer:
|
|
// Supervision: a device with calls off does not ring, the caller is
|
|
// answered busy, same as any other unavailable state.
|
|
if ParentalControls.callsDisabled {
|
|
ignored("calls disabled by supervision", signal.callID)
|
|
endedCallIDs.insert(signal.callID)
|
|
Task {
|
|
try? await inbox.sendCallSignal(
|
|
.init(action: .end, callID: signal.callID, sdp: nil, reason: "busy"),
|
|
to: userID)
|
|
}
|
|
return
|
|
}
|
|
// An offer older than the caller's own patience is not a call.
|
|
if Date().timeIntervalSince(sentAt) > dialTimeout {
|
|
ignored(
|
|
"offer older than \(Int(dialTimeout))s "
|
|
+ "(age \(Int(Date().timeIntervalSince(sentAt)))s)", signal.callID)
|
|
inbox.noteMissedCall(from: userID, at: sentAt)
|
|
endedCallIDs.insert(signal.callID)
|
|
return
|
|
}
|
|
guard case .idle = state else {
|
|
ignored("not idle (state \(state))", signal.callID)
|
|
// Already on a call (or deciding): answer busy, unless it's a
|
|
// duplicate of the offer we're already showing.
|
|
if signal.callID != callID {
|
|
Task {
|
|
try? await inbox.sendCallSignal(
|
|
.init(action: .end, callID: signal.callID, sdp: nil, reason: "busy"),
|
|
to: userID
|
|
)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
peerID = userID
|
|
peerName = inbox.displayName(for: userID)
|
|
callID = signal.callID
|
|
pendingOffer = (signal, userID)
|
|
wasConnected = false
|
|
wasIncoming = true
|
|
isVideo = signal.video
|
|
state = .ringing
|
|
// Announce it as a CALL.
|
|
NotificationManager.shared.postIncomingCall(
|
|
fromBuddyID: userID, fromName: peerName, video: signal.video)
|
|
startFastPolling()
|
|
|
|
case .answer:
|
|
guard case .dialing = state, signal.callID == callID,
|
|
let pc = peerConnection, let sdp = signal.sdp
|
|
else {
|
|
return ignored("answer did not match a dialing call", signal.callID)
|
|
}
|
|
state = .connecting
|
|
armConnectTimeout()
|
|
Task {
|
|
do {
|
|
try await pc.setRemoteDescription(
|
|
RTCSessionDescription(type: .answer, sdp: sdp))
|
|
// The delegate flips to .active when ICE actually connects.
|
|
} catch {
|
|
finish(reason: "Couldn't connect", notifyPeer: true)
|
|
}
|
|
}
|
|
|
|
case .end:
|
|
guard signal.callID == callID else { return }
|
|
let label: String
|
|
switch signal.reason {
|
|
case "declined": label = "Declined"
|
|
case "busy": label = "Busy"
|
|
case "timeout": label = "No answer"
|
|
default: label = "Call ended"
|
|
}
|
|
finish(reason: label, notifyPeer: false)
|
|
}
|
|
}
|
|
|
|
func acceptIncomingCall() {
|
|
identityReviewPeer = nil
|
|
guard case .ringing = state, let pending = pendingOffer,
|
|
let sdp = pending.signal.sdp
|
|
else { return }
|
|
state = .connecting
|
|
armConnectTimeout()
|
|
Task {
|
|
do {
|
|
let pc = try await makePeerConnection(video: pending.signal.video)
|
|
try await pc.setRemoteDescription(RTCSessionDescription(type: .offer, sdp: sdp))
|
|
let answer = try await createLocalDescription(pc, offer: false)
|
|
try await sendAnswer(answer, video: pending.signal.video, to: pending.from)
|
|
} catch {
|
|
os_log(
|
|
"accept failed: %{public}@",
|
|
log: OSLog(subsystem: "io.flamenet.messenger", category: "calls"),
|
|
type: .error, String(describing: error))
|
|
// Same trust gate as startCall: the ANSWER is a send too, and
|
|
// it is refused to an unreviewed identity.
|
|
if case E2EError.identityChanged = error {
|
|
identityReviewPeer = peerID
|
|
finish(reason: "Their security code changed", notifyPeer: false)
|
|
} else {
|
|
finish(reason: "Couldn't connect", notifyPeer: true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Send the answer, retrying once through a gateway hiccup (ios#6).
|
|
private func sendAnswer(_ sdp: String, video: Bool, to peer: Int) async throws {
|
|
// `.init` rather than naming the type: `CallSignal` is nested, and the
|
|
// parameter position already knows what it is.
|
|
func post() async throws {
|
|
try await inbox.sendCallSignal(
|
|
.init(
|
|
action: .answer, callID: callID, sdp: sdp,
|
|
reason: nil, video: video),
|
|
to: peer)
|
|
}
|
|
do {
|
|
try await post()
|
|
} catch MessengerError.httpStatus(let code) where [502, 503, 504].contains(code) {
|
|
os_log(
|
|
"accept: HTTP %d from the relay, retrying the answer once",
|
|
log: OSLog(subsystem: "io.flamenet.messenger", category: "calls"),
|
|
type: .info, code)
|
|
try await Task.sleep(nanoseconds: 1_200_000_000)
|
|
try await post()
|
|
}
|
|
}
|
|
|
|
func declineIncomingCall() {
|
|
guard case .ringing = state else { return }
|
|
finish(reason: "Declined", notifyPeer: true, endReason: "declined")
|
|
}
|
|
|
|
func hangUp() {
|
|
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() {
|
|
isMuted.toggle()
|
|
audioTrack?.isEnabled = !isMuted
|
|
}
|
|
|
|
func toggleSpeaker() {
|
|
isSpeakerOn.toggle()
|
|
let session = RTCAudioSession.sharedInstance()
|
|
session.lockForConfiguration()
|
|
try? session.overrideOutputAudioPort(isSpeakerOn ? .speaker : .none)
|
|
session.unlockForConfiguration()
|
|
}
|
|
|
|
/// Clear the "Call ended" tombstone (the UI's dismiss button).
|
|
func acknowledgeEnd() {
|
|
if case .ended = state { state = .idle }
|
|
}
|
|
|
|
// MARK: - Peer connection plumbing
|
|
|
|
private func makePeerConnection(video: Bool = false) async throws -> RTCPeerConnection {
|
|
let config = RTCConfiguration()
|
|
config.sdpSemantics = .unifiedPlan
|
|
// Non-trickle (§6.8): gather once, send the complete SDP.
|
|
config.continualGatheringPolicy = .gatherOnce
|
|
|
|
// TURN first; fall back to a bare config (STUN-less host candidates
|
|
// still connect same-network peers) rather than failing the call.
|
|
if let creds = try? await inbox.turnCredentials() {
|
|
config.iceServers = [
|
|
RTCIceServer(
|
|
urlStrings: creds.urls,
|
|
username: creds.username,
|
|
credential: creds.credential)
|
|
]
|
|
}
|
|
|
|
let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil)
|
|
guard
|
|
let pc = Self.factory.peerConnection(
|
|
with: config, constraints: constraints,
|
|
delegate: self)
|
|
else {
|
|
throw E2EError.crypto("peer connection init failed")
|
|
}
|
|
|
|
let source = Self.factory.audioSource(with: constraints)
|
|
let track = Self.factory.audioTrack(with: source, trackId: "audio0")
|
|
pc.add(track, streamIds: ["call"])
|
|
|
|
if video {
|
|
// A missing or denied camera degrades to voice rather than failing
|
|
// the call, the simulator has no camera at all.
|
|
if await AVCaptureDevice.requestAccess(for: .video),
|
|
let camera = RTCCameraVideoCapturer.captureDevices()
|
|
.first(where: { $0.position == .front })
|
|
?? RTCCameraVideoCapturer.captureDevices().first
|
|
{
|
|
let videoSource = Self.factory.videoSource()
|
|
let capturer = RTCCameraVideoCapturer(delegate: videoSource)
|
|
let vTrack = Self.factory.videoTrack(with: videoSource, trackId: "video0")
|
|
let sender = pc.add(vTrack, streamIds: ["call"])
|
|
// A cap, so video through the shared TURN cannot saturate the
|
|
// relay host. ~800 kbps is comfortable 540p.
|
|
if let sender {
|
|
let params = sender.parameters
|
|
params.encodings.forEach { $0.maxBitrateBps = 800_000 }
|
|
sender.parameters = params
|
|
}
|
|
// A format near 960x540 at ~24fps; the first camera format
|
|
// otherwise, because refusing to call over format taste is
|
|
// silly.
|
|
let formats = RTCCameraVideoCapturer.supportedFormats(for: camera)
|
|
let format = formats.min(by: { a, b in
|
|
let da = abs(
|
|
Int(CMVideoFormatDescriptionGetDimensions(a.formatDescription).width) - 960)
|
|
let db = abs(
|
|
Int(CMVideoFormatDescriptionGetDimensions(b.formatDescription).width) - 960)
|
|
return da < db
|
|
})
|
|
if let format {
|
|
let fps =
|
|
format.videoSupportedFrameRateRanges
|
|
.map { Int($0.maxFrameRate) }.min(by: { abs($0 - 24) < abs($1 - 24) }) ?? 24
|
|
// Throwing here is a broken camera stack, not a broken
|
|
// call, the call proceeds as voice.
|
|
try? await capturer.startCapture(
|
|
with: camera, format: format,
|
|
fps: min(fps, 30))
|
|
}
|
|
videoCapturer = capturer
|
|
localVideoTrack = vTrack
|
|
}
|
|
}
|
|
|
|
peerConnection = pc
|
|
audioTrack = track
|
|
return pc
|
|
}
|
|
|
|
/// Create offer/answer, set it locally, and wait for ICE gathering to
|
|
/// complete so the returned SDP carries every candidate.
|
|
private func createLocalDescription(_ pc: RTCPeerConnection, offer: Bool) async throws -> String
|
|
{
|
|
let constraints = RTCMediaConstraints(
|
|
mandatoryConstraints: ["OfferToReceiveAudio": "true"],
|
|
optionalConstraints: nil
|
|
)
|
|
let description: RTCSessionDescription = try await withCheckedThrowingContinuation { cont in
|
|
// `@Sendable` to match what WebRTC declares.
|
|
let handler: @Sendable (RTCSessionDescription?, Error?) -> Void = { sdp, error in
|
|
if let sdp {
|
|
cont.resume(returning: sdp)
|
|
} else {
|
|
cont.resume(throwing: error ?? E2EError.crypto("no sdp"))
|
|
}
|
|
}
|
|
if offer {
|
|
pc.offer(for: constraints, completionHandler: handler)
|
|
} else {
|
|
pc.answer(for: constraints, completionHandler: handler)
|
|
}
|
|
}
|
|
try await pc.setLocalDescription(description)
|
|
|
|
if pc.iceGatheringState != .complete {
|
|
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
|
|
gatheringWaiter = cont
|
|
// Re-check AFTER arming.
|
|
if pc.iceGatheringState == .complete {
|
|
gatheringWaiter = nil
|
|
cont.resume()
|
|
return
|
|
}
|
|
// And never wait without a limit: a TURN server that is
|
|
// configured but unreachable stalls gathering for tens of
|
|
// seconds, which reads as the same freeze.
|
|
Task { [weak self] in
|
|
try? await Task.sleep(nanoseconds: 8_000_000_000)
|
|
guard let self else { return }
|
|
self.gatheringWaiter?.resume()
|
|
self.gatheringWaiter = nil
|
|
}
|
|
}
|
|
}
|
|
guard let local = pc.localDescription else {
|
|
throw E2EError.crypto("no local description after gathering")
|
|
}
|
|
return local.sdp
|
|
}
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
/// Poll hard while a call is being set up, the 5 s cadence would make
|
|
/// answers feel geological.
|
|
private func startFastPolling() {
|
|
fastPollTask?.cancel()
|
|
fastPollTask = Task { [weak self] in
|
|
while let self, !Task.isCancelled {
|
|
switch self.state {
|
|
// `.active` belongs here and its absence was the hang-up lag.
|
|
case .dialing, .ringing, .connecting, .active:
|
|
await self.inbox.pollOnce()
|
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func armDialTimeout() {
|
|
timeoutTask?.cancel()
|
|
timeoutTask = Task { [weak self] in
|
|
try? await Task.sleep(nanoseconds: UInt64(45 * 1_000_000_000))
|
|
guard let self, !Task.isCancelled else { return }
|
|
if case .dialing = self.state {
|
|
self.finish(reason: "No answer", notifyPeer: true, endReason: "timeout")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Gives up a call that was answered but whose media never connected.
|
|
private func armConnectTimeout() {
|
|
timeoutTask?.cancel()
|
|
timeoutTask = Task { [weak self] in
|
|
try? await Task.sleep(nanoseconds: UInt64(30 * 1_000_000_000))
|
|
guard let self, !Task.isCancelled else { return }
|
|
if case .connecting = self.state {
|
|
self.finish(reason: "Couldn't connect", notifyPeer: true, endReason: "timeout")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func finish(reason: String, notifyPeer: Bool, endReason: String = "hangup") {
|
|
// A banner still saying "Incoming call" after the caller hung up is
|
|
// worse than none, it sends somebody back to an app with nothing
|
|
// ringing in it.
|
|
if peerID != 0 { NotificationManager.shared.clearDelivered(forBuddyID: peerID) }
|
|
if notifyPeer, !callID.isEmpty, peerID != 0 {
|
|
let signal = E2EAttachment.CallSignal(
|
|
action: .end, callID: callID,
|
|
sdp: nil, reason: endReason)
|
|
let target = peerID
|
|
Task { [inbox] in try? await inbox.sendCallSignal(signal, to: target) }
|
|
}
|
|
if !callID.isEmpty { endedCallIDs.insert(callID) }
|
|
gatheringWaiter?.resume()
|
|
gatheringWaiter = nil
|
|
timeoutTask?.cancel()
|
|
fastPollTask?.cancel()
|
|
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()
|
|
videoCapturer = nil
|
|
localVideoTrack = nil
|
|
remoteVideoTrack = nil
|
|
isVideo = false
|
|
pendingOffer = nil
|
|
isMuted = false
|
|
isSpeakerOn = false
|
|
// File it before the state changes, and read `wasConnected` from the
|
|
// value captured while the call was still up.
|
|
if peerID != 0 {
|
|
let peer = peerID
|
|
let incoming = wasIncoming
|
|
let connected = wasConnected
|
|
Task { [inbox] in
|
|
inbox.recordCall(peerID: peer, incoming: incoming, connected: connected)
|
|
}
|
|
}
|
|
wasConnected = false
|
|
|
|
state = .ended(reason)
|
|
// Auto-clear the tombstone after a beat so the UI can dismiss itself.
|
|
Task { [weak self] in
|
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
|
self?.acknowledgeEnd()
|
|
}
|
|
}
|
|
|
|
/// The caller's name, if a call is ringing right now.
|
|
func ringingCallerName() -> String? {
|
|
guard state == .ringing, !peerName.isEmpty else { return nil }
|
|
return peerName
|
|
}
|
|
|
|
func teardown() {
|
|
if case .idle = state {
|
|
} else {
|
|
finish(reason: "Call ended", notifyPeer: true)
|
|
}
|
|
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)
|
|
|
|
extension CallManager: RTCPeerConnectionDelegate {
|
|
nonisolated func peerConnection(
|
|
_ peerConnection: RTCPeerConnection,
|
|
didChange newState: RTCIceGatheringState
|
|
) {
|
|
guard newState == .complete else { return }
|
|
Task { @MainActor in
|
|
self.gatheringWaiter?.resume()
|
|
self.gatheringWaiter = nil
|
|
}
|
|
}
|
|
|
|
nonisolated func peerConnection(
|
|
_ peerConnection: RTCPeerConnection,
|
|
didChange newState: RTCIceConnectionState
|
|
) {
|
|
Task { @MainActor in
|
|
switch newState {
|
|
case .connected, .completed:
|
|
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)
|
|
case .disconnected:
|
|
// Transient per WebRTC docs; only .failed is terminal.
|
|
break
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated func peerConnection(
|
|
_ peerConnection: RTCPeerConnection,
|
|
didChange stateChanged: RTCSignalingState
|
|
) {}
|
|
nonisolated func peerConnection(
|
|
_ peerConnection: RTCPeerConnection,
|
|
didAdd stream: RTCMediaStream
|
|
) {
|
|
// Their camera.
|
|
let track = stream.videoTracks.first
|
|
Task { @MainActor in
|
|
if let track { self.remoteVideoTrack = track }
|
|
}
|
|
}
|
|
nonisolated func peerConnection(
|
|
_ peerConnection: RTCPeerConnection,
|
|
didRemove stream: RTCMediaStream
|
|
) {}
|
|
nonisolated func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) {}
|
|
nonisolated func peerConnection(
|
|
_ peerConnection: RTCPeerConnection,
|
|
didGenerate candidate: RTCIceCandidate
|
|
) {
|
|
// Non-trickle: candidates end up inside the gathered SDP instead.
|
|
}
|
|
nonisolated func peerConnection(
|
|
_ peerConnection: RTCPeerConnection,
|
|
didRemove candidates: [RTCIceCandidate]
|
|
) {}
|
|
nonisolated func peerConnection(
|
|
_ peerConnection: RTCPeerConnection,
|
|
didOpen dataChannel: RTCDataChannel
|
|
) {}
|
|
}
|