Files
Luma/Sources/Shared/XMPP/LumaCallEngine.swift
T
wt 10e18eecb6
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
fix ios ci test callsdpnormalization
2026-08-11 14:42:15 +07:00

1830 lines
67 KiB
Swift

import AVFoundation
import Foundation
import Martin
import WebRTC
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
/// Owns one active 1:1 Jingle RTP session and bridges Martin to WebRTC.
/// Incoming calls are available while the XMPP connection is alive; production
/// wake-up for a terminated iOS app still requires VoIP push infrastructure.
final class LumaCallEngine: NSObject, @unchecked Sendable {
var snapshotHandler: (@MainActor (CallSnapshot?) -> Void)?
var historyHandler: (@MainActor (CallHistoryEntry) -> Void)?
var errorHandler: (@MainActor (String) -> Void)?
private let queue = DispatchQueue(label: "app.luma.call-engine")
private let queueKey = DispatchSpecificKey<UInt8>()
fileprivate let peerConnectionFactory: RTCPeerConnectionFactory
private weak var client: XMPPClient?
private var sessions: [String: LumaJingleSession] = [:]
private var activeCall: LumaWebRTCCall?
private var timeoutWorkItem: DispatchWorkItem?
override init() {
RTCInitializeSSL()
peerConnectionFactory = RTCPeerConnectionFactory(
encoderFactory: RTCDefaultVideoEncoderFactory(),
decoderFactory: RTCDefaultVideoDecoderFactory()
)
super.init()
queue.setSpecific(key: queueKey, value: 1)
}
func attach(client: XMPPClient) {
performSync {
self.client = client
}
}
func detach() {
queue.async {
self.finishActiveCall(
cause: .connectionDetached,
sendTermination: false,
trigger: "XMPP engine detached"
)
self.sessions.removeAll()
self.client = nil
}
}
func startCall(
to peerJID: String,
withVideo: Bool,
completion: @escaping (Result<Void, Error>) -> Void
) {
queue.async {
guard self.activeCall == nil else {
completion(.failure(LumaCallError.callAlreadyActive))
return
}
guard let client = self.client, client.state == .connected() else {
completion(.failure(LumaCallError.notConnected))
return
}
let peer = BareJID(peerJID.lowercased())
let media: Set<CallMedia> = withVideo ? [.audio, .video] : [.audio]
do {
let destination = try self.destination(
for: peer,
media: media,
client: client
)
#if DEBUG
print(
"Luma Call: selected \(destination.jid.stringValue) via "
+ (destination.usesMessageInitiation ? "JMI" : "direct Jingle")
+ " for \(media.map(\.rawValue).sorted().joined(separator: "+"))."
)
#endif
let sid = UUID().uuidString.lowercased()
let initiationType: JingleSessionInitiationType =
destination.usesMessageInitiation
? .message
: .iq
let session = self.makeSession(
context: client,
jid: destination.jid,
sid: sid,
role: .initiator,
initiationType: initiationType
)
let call = LumaWebRTCCall(
engine: self,
session: session,
peerJID: peer.stringValue,
direction: .outgoing,
media: media
)
self.activeCall = call
self.publish(call)
self.scheduleTimeout(for: call.id, after: 60, stage: "ringing")
if destination.usesMessageInitiation {
Task {
do {
try await session.initiate(
descriptions:
media
.sorted { $0.rawValue < $1.rawValue }
.map {
Jingle.MessageInitiationAction.Description(
xmlns: "urn:xmpp:jingle:apps:rtp:1",
media: $0.rawValue
)
})
completion(.success(()))
} catch {
self.queue.async {
self.fail(call, error: error)
}
completion(.failure(error))
}
}
} else {
call.preparePeerConnection { result in
switch result {
case .success:
call.bindSessionActions()
call.sendOffer { result in
if case .failure(let error) = result {
self.fail(call, error: error)
}
completion(result)
}
case .failure(let error):
self.fail(call, error: error)
completion(.failure(error))
}
}
}
} catch {
completion(.failure(error))
}
}
}
func answerCall(completion: @escaping (Result<Void, Error>) -> Void) {
queue.async {
guard let call = self.activeCall,
call.direction == .incoming,
call.phase == .ringing
else {
completion(.failure(LumaCallError.noIncomingCall))
return
}
call.preparePeerConnection { result in
switch result {
case .success:
call.phase = .connecting
call.session.accept()
call.bindSessionActions()
self.scheduleTimeout(
for: call.id,
after: 90,
stage: "incoming negotiation"
)
self.publish(call)
completion(.success(()))
case .failure(let error):
self.fail(call, error: error)
completion(.failure(error))
}
}
}
}
func rejectCall() {
queue.async {
guard self.activeCall != nil else { return }
self.finishActiveCall(
reason: .decline,
cause: .localRejected,
sendTermination: true,
trigger: "local reject button"
)
}
}
func endCall() {
queue.async {
self.finishActiveCall(
reason: .success,
cause: .localEnded,
sendTermination: true,
trigger: "local end button"
)
}
}
func setMuted(_ muted: Bool) {
queue.async {
guard let call = self.activeCall else { return }
call.isMuted = muted
call.localAudioTrack?.isEnabled = !muted
self.publish(call)
}
}
func setCameraEnabled(_ enabled: Bool) {
queue.async {
guard let call = self.activeCall, call.media.contains(.video) else { return }
call.isCameraEnabled = enabled
call.localVideoTrack?.isEnabled = enabled
self.publish(call)
}
}
func setSpeakerEnabled(_ enabled: Bool) {
queue.async {
guard let call = self.activeCall else { return }
do {
try call.configureSpeaker(enabled)
call.isSpeakerEnabled = enabled
self.publish(call)
} catch {
self.report(error)
}
}
}
func switchCamera() {
queue.async {
self.activeCall?.switchCamera()
}
}
func localVideoTrack(for callID: UUID) -> RTCVideoTrack? {
performSync {
guard activeCall?.id == callID else { return nil }
return activeCall?.localVideoTrack
}
}
func remoteVideoTrack(for callID: UUID) -> RTCVideoTrack? {
performSync {
guard activeCall?.id == callID else { return nil }
return activeCall?.remoteVideoTrack
}
}
fileprivate func iceServers(
for client: XMPPClient,
completion: @escaping ([RTCIceServer]) -> Void
) {
let fallback = Self.publicSTUNServers()
guard let module = client.moduleOrNil(.externalServiceDiscovery), module.isAvailable else {
completion(fallback)
return
}
module.discover(from: nil, type: nil) { result in
let servers: [RTCIceServer]
switch result {
case .success(let services):
servers = services.compactMap { $0.rtcIceServer }
case .failure:
servers = []
}
completion(servers.isEmpty ? fallback : servers)
}
}
fileprivate static func publicSTUNServers() -> [RTCIceServer] {
[RTCIceServer(urlStrings: ["stun:stun.l.google.com:19302"])]
}
fileprivate func publish(_ call: LumaWebRTCCall?) {
let snapshot = call?.snapshot
Task { @MainActor [weak self] in
self?.snapshotHandler?(snapshot)
}
}
fileprivate func callDidConnect(_ call: LumaWebRTCCall) {
queue.async {
guard self.activeCall === call else { return }
self.timeoutWorkItem?.cancel()
call.phase = .connected
if call.connectedAt == nil { call.connectedAt = Date() }
self.publish(call)
}
}
fileprivate func callDidLoseConnection(_ call: LumaWebRTCCall) {
queue.async {
guard self.activeCall === call else { return }
self.fail(call, error: LumaCallError.connectionLost)
}
}
fileprivate func callDidFail(_ call: LumaWebRTCCall, error: Error) {
queue.async {
guard self.activeCall === call else { return }
self.fail(call, error: error)
}
}
fileprivate func callVideoTracksDidChange(_ call: LumaWebRTCCall) {
queue.async {
guard self.activeCall === call else { return }
self.publish(call)
}
}
private func destination(
for peer: BareJID,
media: Set<CallMedia>,
client: XMPPClient
) throws -> (jid: JID, usesMessageInitiation: Bool) {
let presences = client.module(.presence).store.presences(for: peer, context: client)
guard !presences.isEmpty else { throw LumaCallError.contactOffline }
let capabilities = client.module(.caps)
let candidates = presences.compactMap(\.from).filter { jid in
guard capabilities.isFeatureSupported(JingleModule.XMLNS, by: jid),
capabilities.isFeatureSupported(Jingle.Transport.ICEUDPTransport.XMLNS, by: jid),
capabilities.isFeatureSupported("urn:xmpp:jingle:apps:rtp:audio", by: jid)
else {
return false
}
return !media.contains(.video)
|| capabilities.isFeatureSupported("urn:xmpp:jingle:apps:rtp:video", by: jid)
}
guard let directJID = candidates.first else {
throw LumaCallError.unsupportedByContact
}
let jmiJID = candidates.first {
capabilities.isFeatureSupported(JingleModule.MESSAGE_INITIATION_XMLNS, by: $0)
}
// A proposal addressed to the bare JID can return a bare `proceed` on
// some server/client combinations. We would then have no safe endpoint
// for transport-info: sending it to the bare JID produces
// item-not-found, while waiting for a resource leaves ICE without any
// candidates. Address JMI to one advertised full JID instead. It still
// uses XEP-0353, but every stanza now belongs to the same resource.
if let jmiJID {
return (jid: jmiJID, usesMessageInitiation: true)
}
return (jid: directJID, usesMessageInitiation: false)
}
private func makeSession(
context: Context,
jid: JID,
sid: String,
role: Jingle.Content.Creator,
initiationType: JingleSessionInitiationType
) -> LumaJingleSession {
let session = LumaJingleSession(
context: context,
jid: jid,
sid: sid,
role: role,
initiationType: initiationType
)
sessions[sid] = session
return session
}
private func scheduleTimeout(
for callID: UUID,
after delay: TimeInterval,
stage: String
) {
timeoutWorkItem?.cancel()
let item = DispatchWorkItem { [weak self] in
guard let self, self.activeCall?.id == callID else { return }
#if DEBUG
print("Luma Call: timeout during \(stage) after \(Int(delay)) seconds.")
#endif
self.fail(self.activeCall, error: LumaCallError.timedOut)
}
timeoutWorkItem = item
queue.asyncAfter(deadline: .now() + delay, execute: item)
}
private func fail(_ call: LumaWebRTCCall?, error: Error) {
guard call == nil || activeCall === call else { return }
let text = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
#if DEBUG
print("Luma Call: failure: \(text)")
#endif
report(error)
let cause: CallTerminationCause
if let callError = error as? LumaCallError, case .timedOut = callError {
cause = .timedOut
} else {
cause = .failed
}
finishActiveCall(
reason: .failedApplication,
cause: cause,
sendTermination: true,
trigger: "failure: \(text)"
)
}
private func report(_ error: Error) {
let text = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
Task { @MainActor [weak self] in
self?.errorHandler?(text)
}
}
private func finishActiveCall(
reason: JingleSessionTerminateReason = .success,
cause: CallTerminationCause,
sendTermination: Bool,
trigger: String
) {
timeoutWorkItem?.cancel()
timeoutWorkItem = nil
guard let call = activeCall else {
publish(nil)
return
}
#if DEBUG
print(
"Luma Call: closing sid=\(call.session.sid) "
+ "direction=\(String(describing: call.direction)) "
+ "phase=\(String(describing: call.phase)) "
+ "peer=\(call.session.jid.stringValue) trigger=\(trigger)."
)
#endif
let endedAt = Date()
let outcome = CallHistoryPolicy.outcome(
direction: call.direction,
phase: call.phase,
connectedAt: call.connectedAt,
cause: cause
)
let historyEntry = CallHistoryEntry(
id: "call-\(call.peerJID)-\(call.session.sid)",
peerJID: call.peerJID,
direction: call.direction,
isVideo: call.media.contains(.video),
startedAt: call.startedAt,
endedAt: endedAt,
duration: call.connectedAt.map { max(0, endedAt.timeIntervalSince($0)) },
outcome: outcome
)
activeCall = nil
sessions.removeValue(forKey: call.session.sid)
if sendTermination {
call.session.finish(reason: reason)
} else {
call.session.terminated()
}
call.closePeerConnection()
publish(nil)
publish(historyEntry)
}
private func publish(_ historyEntry: CallHistoryEntry) {
Task { @MainActor [weak self] in
self?.historyHandler?(historyEntry)
}
}
private func performSync<T>(_ operation: () throws -> T) rethrows -> T {
if DispatchQueue.getSpecific(key: queueKey) != nil {
return try operation()
}
return try queue.sync(execute: operation)
}
}
// MARK: - Martin Jingle session manager
extension LumaCallEngine: JingleSessionManager {
func messageInitiation(
for context: Context,
from jid: JID,
action: Jingle.MessageInitiationAction
) throws {
try performSync {
switch action {
case .propose(let id, let descriptions):
guard activeCall == nil else {
context.module(.jingle).sendMessageInitiation(action: .reject(id: id), to: jid)
return
}
let media = Set(descriptions.compactMap { CallMedia(rawValue: $0.media) })
guard media.contains(.audio) else {
context.module(.jingle).sendMessageInitiation(action: .reject(id: id), to: jid)
throw XMPPError.feature_not_implemented
}
let session = makeSession(
context: context,
jid: jid,
sid: id,
role: .responder,
initiationType: .message
)
let call = LumaWebRTCCall(
engine: self,
session: session,
peerJID: jid.bareJid.stringValue,
direction: .incoming,
media: media
)
activeCall = call
publish(call)
scheduleTimeout(for: call.id, after: 60, stage: "incoming ringing")
case .proceed(let id):
guard let session = sessions[id],
let call = activeCall,
call.session === session,
call.direction == .outgoing,
call.phase == .ringing,
session.pinPeerResource(jid)
else {
#if DEBUG
print(
"Luma Jingle: ignored duplicate or foreign proceed for session \(id).")
#endif
return
}
call.phase = .connecting
#if DEBUG
print("Luma Jingle: proceed from \(jid.stringValue) for session \(id).")
#endif
scheduleTimeout(
for: call.id,
after: 90,
stage: "outgoing negotiation"
)
publish(call)
call.preparePeerConnection { result in
switch result {
case .success:
call.bindSessionActions()
call.sendOffer { result in
if case .failure(let error) = result {
self.fail(call, error: error)
}
}
case .failure(let error):
self.fail(call, error: error)
}
}
case .retract(let id):
guard let session = sessions[id],
session.matchesPeerEndpoint(jid)
else { return }
finishActiveCall(
cause: .remoteCancelled,
sendTermination: false,
trigger: "remote JMI retract from \(jid.stringValue)"
)
case .reject(let id):
guard let session = sessions[id],
let call = activeCall,
call.session === session,
session.matchesPeerEndpoint(jid)
else { return }
// A JMI proposal is delivered to every online resource. Once a
// resource has proceeded, a delayed reject from a sibling must
// not tear down the session that already owns the call.
guard call.phase == .ringing else {
#if DEBUG
print("Luma Jingle: ignored late reject after proceed for session \(id).")
#endif
return
}
finishActiveCall(
cause: .remoteRejected,
sendTermination: false,
trigger: "remote JMI reject from \(jid.stringValue)"
)
case .accept(let id):
// A carbon from another local resource means that device
// answered the incoming proposal.
guard jid.bareJid == context.userBareJid, sessions[id] != nil else { return }
finishActiveCall(
cause: .answeredElsewhere,
sendTermination: false,
trigger: "answered on another local resource \(jid.stringValue)"
)
}
}
}
func sessionInitiated(
for context: Context,
with jid: JID,
sid: String,
contents: [Jingle.Content],
bundle: [String]?
) throws {
try performSync {
let media = Set(
contents.compactMap { CallMedia(rawValue: $0.description?.media ?? "") })
guard media.contains(.audio) else { throw XMPPError.feature_not_implemented }
if let existing = sessions[sid] {
guard existing.pinPeerResource(jid) else {
throw XMPPError.item_not_found
}
existing.initiated(contents: contents, bundle: bundle)
if let call = activeCall, call.session === existing {
scheduleTimeout(
for: call.id,
after: 90,
stage: "incoming offer negotiation"
)
}
return
}
guard activeCall == nil else {
throw XMPPError.resource_constraint("Другой звонок уже активен")
}
let session = makeSession(
context: context,
jid: jid,
sid: sid,
role: .responder,
initiationType: .iq
)
session.initiated(contents: contents, bundle: bundle)
let call = LumaWebRTCCall(
engine: self,
session: session,
peerJID: jid.bareJid.stringValue,
direction: .incoming,
media: media
)
activeCall = call
publish(call)
scheduleTimeout(for: call.id, after: 60, stage: "incoming ringing")
}
}
func sessionAccepted(
for context: Context,
with jid: JID,
sid: String,
contents: [Jingle.Content],
bundle: [String]?
) throws {
try performSync {
guard let session = sessions[sid],
session.pinPeerResource(jid)
else {
throw XMPPError.item_not_found
}
// JMI may initially address a bare JID. session-accept is the first
// IQ guaranteed to identify the resource that owns this Jingle
// session, so keep every later transport-info stanza on it.
#if DEBUG
let acceptedMedia = contents.map {
"\($0.name):\($0.description?.media ?? "unknown")"
}.joined(separator: ",")
print(
"Luma Jingle: session-accept from \(jid.stringValue) "
+ "sid=\(sid) contents=[\(acceptedMedia)]."
)
#endif
session.accepted(contents: contents, bundle: bundle)
if let call = activeCall, call.session === session {
call.phase = .connecting
scheduleTimeout(
for: call.id,
after: 90,
stage: "ICE negotiation"
)
publish(call)
}
}
}
func sessionTerminated(for context: Context, with jid: JID, sid: String) throws {
try performSync {
guard let session = sessions[sid],
session.matchesPeerEndpoint(jid)
else {
throw XMPPError.item_not_found
}
if activeCall?.session.sid == sid {
finishActiveCall(
cause: .remoteEnded,
sendTermination: false,
trigger: "remote session-terminate from \(jid.stringValue)"
)
} else {
session.terminated()
sessions.removeValue(forKey: sid)
}
}
}
func transportInfo(
for context: Context,
with jid: JID,
sid: String,
contents: [Jingle.Content]
) throws {
try performSync {
guard let session = sessions[sid],
session.pinPeerResource(jid)
else {
throw XMPPError.item_not_found
}
// Also learn the endpoint from an early candidate sent by the
// selected resource. This covers clients that trickle before their
// session-accept reaches us.
for content in contents {
for transport in content.transports {
guard let ice = transport as? Jingle.Transport.ICEUDPTransport else { continue }
ice.candidates.forEach { session.addCandidate($0, contentName: content.name) }
}
}
}
}
func contentModified(
for context: Context,
with jid: JID,
sid: String,
action: Jingle.ContentAction,
contents: [Jingle.Content],
bundle: [String]?
) throws {
try performSync {
guard let session = sessions[sid],
session.matchesPeerEndpoint(jid)
else {
throw XMPPError.item_not_found
}
session.contentModified(action: action, contents: contents, bundle: bundle)
}
}
func sessionInfo(
for context: Context,
with jid: JID,
sid: String,
info: [Jingle.SessionInfo]
) throws {
try performSync {
guard let session = sessions[sid],
session.matchesPeerEndpoint(jid)
else {
throw XMPPError.item_not_found
}
session.sessionInfoReceived(info: info)
}
}
}
// MARK: - Session action queue
private final class LumaJingleSession: JingleSession {
enum Action {
case contentSet(SDP)
case contentApply(Jingle.ContentAction, SDP)
case transportAdd(Jingle.Transport.ICEUDPTransport.Candidate, String)
case sessionInfo([Jingle.SessionInfo])
}
private let lock = NSLock()
private weak var owningContext: Context?
private var pendingActions: [Action] = []
private var actionHandler: ((Action) -> Void)?
override init(
context: Context,
jid: JID,
sid: String,
role: Jingle.Content.Creator,
initiationType: JingleSessionInitiationType
) {
owningContext = context
super.init(
context: context,
jid: jid,
sid: sid,
role: role,
initiationType: initiationType
)
}
func finish(reason: JingleSessionTerminateReason) {
guard state != .terminated else { return }
guard initiationType == .message, state != .accepted else {
terminate(reason: reason)
return
}
let action: Jingle.MessageInitiationAction =
role == .initiator
? .retract(id: sid)
: .reject(id: sid)
owningContext?.module(.jingle).sendMessageInitiation(action: action, to: jid)
terminated()
}
/// Pins a JMI session to the concrete endpoint that answered the call.
/// Martin intentionally exposes `jid` as read-only; `accepted(by:)` is its
/// supported resource-rebinding operation.
@discardableResult
func pinPeerResource(_ peer: JID) -> Bool {
guard peer.bareJid == jid.bareJid else { return false }
guard peer.resource != nil else { return true }
if jid.resource != nil {
return peer == jid
}
#if DEBUG
print("Luma Jingle: pinned session \(sid) to resource \(peer.stringValue).")
#endif
accepted(by: peer)
return true
}
func matchesPeerEndpoint(_ peer: JID) -> Bool {
guard peer.bareJid == jid.bareJid else { return false }
guard jid.resource != nil, peer.resource != nil else { return true }
return peer == jid
}
func setActionHandler(_ handler: @escaping (Action) -> Void) {
lock.lock()
actionHandler = handler
let actions = pendingActions
pendingActions.removeAll()
lock.unlock()
actions.forEach(handler)
}
private func emit(_ action: Action) {
lock.lock()
guard let actionHandler else {
pendingActions.append(action)
lock.unlock()
return
}
lock.unlock()
actionHandler(action)
}
override func initiated(contents: [Jingle.Content], bundle: [String]?) {
super.initiated(contents: contents, bundle: bundle)
emit(.contentSet(SDP(contents: contents, bundle: bundle)))
}
override func accepted(contents: [Jingle.Content], bundle: [String]?) {
super.accepted(contents: contents, bundle: bundle)
emit(.contentSet(SDP(contents: contents, bundle: bundle)))
}
override func contentModified(
action: Jingle.ContentAction,
contents: [Jingle.Content],
bundle: [String]?
) {
emit(.contentApply(action, SDP(contents: contents, bundle: bundle)))
}
override func sessionInfoReceived(info: [Jingle.SessionInfo]) {
emit(.sessionInfo(info))
}
func addCandidate(
_ candidate: Jingle.Transport.ICEUDPTransport.Candidate,
contentName: String
) {
emit(.transportAdd(candidate, contentName))
}
}
// MARK: - Live WebRTC call
private final class LumaWebRTCCall: NSObject, RTCPeerConnectionDelegate, @unchecked Sendable {
let id = UUID()
let startedAt = Date()
unowned let engine: LumaCallEngine
let session: LumaJingleSession
let peerJID: String
let direction: CallDirection
let media: Set<CallMedia>
var phase: CallPhase = .ringing
var connectedAt: Date?
var isMuted = false
var isCameraEnabled: Bool
var isSpeakerEnabled = false
var localAudioTrack: RTCAudioTrack?
var localVideoTrack: RTCVideoTrack?
var remoteVideoTrack: RTCVideoTrack?
private var peerConnection: RTCPeerConnection?
private var videoCapturer: RTCCameraVideoCapturer?
private var localVideoSource: RTCVideoSource?
private var cameraDeviceID: String?
private var localSDP: SDP?
private var remoteSDP: SDP?
private var localCandidates: [RTCIceCandidate] = []
private var remoteCandidates: [(Jingle.Transport.ICEUDPTransport.Candidate, String)] = []
private var initialDescriptionWasSignaled = false
private var connectionFailureWorkItem: DispatchWorkItem?
private var isPreparingPeerConnection = false
private var prepareCallbacks: [(Result<Void, Error>) -> Void] = []
private let webRTCSID = String(UInt64.random(in: UInt64.min...UInt64.max))
init(
engine: LumaCallEngine,
session: LumaJingleSession,
peerJID: String,
direction: CallDirection,
media: Set<CallMedia>
) {
self.engine = engine
self.session = session
self.peerJID = peerJID
self.direction = direction
self.media = media
self.isCameraEnabled = media.contains(.video)
self.isSpeakerEnabled = media.contains(.video)
super.init()
}
var snapshot: CallSnapshot {
CallSnapshot(
id: id,
peerJID: peerJID,
direction: direction,
media: media,
phase: phase,
connectedAt: connectedAt,
isMuted: isMuted,
isCameraEnabled: isCameraEnabled,
isSpeakerEnabled: isSpeakerEnabled,
hasLocalVideo: localVideoTrack != nil,
hasRemoteVideo: remoteVideoTrack != nil
)
}
func preparePeerConnection(completion: @escaping (Result<Void, Error>) -> Void) {
if peerConnection != nil {
completion(.success(()))
return
}
prepareCallbacks.append(completion)
guard !isPreparingPeerConnection else { return }
isPreparingPeerConnection = true
guard let client = engine.performAttachedClient() else {
completePreparation(.failure(LumaCallError.notConnected))
return
}
engine.iceServers(for: client) { [weak self] servers in
guard let self else { return }
self.engine.performOnQueue {
do {
try self.createPeerConnection(iceServers: servers)
self.completePreparation(.success(()))
} catch {
self.completePreparation(.failure(error))
}
}
}
}
func bindSessionActions() {
session.setActionHandler { [weak self] action in
guard let self else { return }
self.engine.performOnQueue {
self.receive(action)
}
}
}
func sendOffer(completion: @escaping (Result<Void, Error>) -> Void) {
guard let peerConnection else {
completion(.failure(LumaCallError.mediaEngineUnavailable))
return
}
generateDescription(type: .offer, peerConnection: peerConnection) { result in
switch result {
case .success(let sdp):
Task {
do {
try await self.session.initiate(contents: sdp.contents, bundle: sdp.bundle)
self.engine.performOnQueue {
self.initialDescriptionWasSignaled = true
self.sendLocalCandidates()
completion(.success(()))
}
} catch {
self.engine.performOnQueue {
completion(.failure(error))
}
}
}
case .failure(let error):
self.engine.performOnQueue {
completion(.failure(error))
}
}
}
}
func configureSpeaker(_ enabled: Bool) throws {
#if os(iOS)
let audioSession = AVAudioSession.sharedInstance()
try audioSession.overrideOutputAudioPort(enabled ? .speaker : .none)
try audioSession.setActive(true)
#else
_ = enabled
#endif
}
func switchCamera() {
guard let capturer = videoCapturer else { return }
let currentPosition =
RTCCameraVideoCapturer.captureDevices()
.first(where: { $0.uniqueID == cameraDeviceID })?.position ?? .front
guard
let device = RTCCameraVideoCapturer.captureDevices().first(where: {
$0.position != currentPosition && $0.position != .unspecified
}), let format = Self.preferredFormat(for: device)
else { return }
cameraDeviceID = device.uniqueID
capturer.startCapture(with: device, format: format, fps: Self.preferredFPS(for: format))
}
func closePeerConnection() {
connectionFailureWorkItem?.cancel()
connectionFailureWorkItem = nil
videoCapturer?.stopCapture()
videoCapturer = nil
localVideoSource = nil
peerConnection?.close()
peerConnection = nil
localAudioTrack = nil
localVideoTrack = nil
remoteVideoTrack = nil
#if os(iOS)
try? AVAudioSession.sharedInstance().setActive(
false,
options: .notifyOthersOnDeactivation
)
#endif
}
private func createPeerConnection(iceServers: [RTCIceServer]) throws {
// libwebrtc rejects the complete RTCConfiguration when even one ICE URL
// is malformed or a TURN entry has incomplete credentials. Prosody
// modules and older XEP-0215 implementations occasionally publish such
// entries, so retry with a known STUN server and finally host candidates.
var attempts: [(name: String, servers: [RTCIceServer])] = []
if !iceServers.isEmpty {
attempts.append(("XEP-0215", iceServers))
}
attempts.append(("public STUN", LumaCallEngine.publicSTUNServers()))
attempts.append(("host candidates", []))
let constraints = RTCMediaConstraints(
mandatoryConstraints: nil,
optionalConstraints: nil
)
var createdPeerConnection: RTCPeerConnection?
for (index, attempt) in attempts.enumerated() {
let configuration = RTCConfiguration()
configuration.iceServers = attempt.servers
configuration.sdpSemantics = .unifiedPlan
configuration.bundlePolicy = .maxCompat
configuration.rtcpMuxPolicy = .require
configuration.iceCandidatePoolSize = 0
if let candidate = engine.peerConnectionFactory.peerConnection(
with: configuration,
constraints: constraints,
delegate: self
) {
createdPeerConnection = candidate
#if DEBUG
if index > 0 {
print("Luma WebRTC: RTCConfiguration recovered with \(attempt.name).")
}
#endif
break
}
#if DEBUG
print(
"Luma WebRTC: RTCConfiguration rejected "
+ "\(attempt.name) (\(attempt.servers.count) ICE server(s))."
)
#endif
}
guard let peerConnection = createdPeerConnection else {
throw LumaCallError.mediaEngineUnavailable
}
self.peerConnection = peerConnection
#if os(iOS)
let audioSession = AVAudioSession.sharedInstance()
let audioOptions: AVAudioSession.CategoryOptions = [
.allowBluetoothHFP,
.allowBluetoothA2DP,
]
try audioSession.setCategory(
.playAndRecord,
mode: media.contains(.video) ? .videoChat : .voiceChat,
options: audioOptions
)
try audioSession.setActive(true)
if media.contains(.video) {
try audioSession.overrideOutputAudioPort(.speaker)
}
#endif
let audioConstraints = RTCMediaConstraints(
mandatoryConstraints: nil,
optionalConstraints: nil
)
let audioSource = engine.peerConnectionFactory.audioSource(with: audioConstraints)
let audioTrack = engine.peerConnectionFactory.audioTrack(
with: audioSource,
trackId: "luma-audio-\(id.uuidString)"
)
localAudioTrack = audioTrack
peerConnection.add(audioTrack, streamIds: ["luma-stream"])
if media.contains(.video) {
let source = engine.peerConnectionFactory.videoSource()
localVideoSource = source
let track = engine.peerConnectionFactory.videoTrack(
with: source,
trackId: "luma-video-\(id.uuidString)"
)
localVideoTrack = track
peerConnection.add(track, streamIds: ["luma-stream"])
let capturer = RTCCameraVideoCapturer(delegate: source)
videoCapturer = capturer
if let device = Self.preferredCamera(), let format = Self.preferredFormat(for: device) {
cameraDeviceID = device.uniqueID
capturer.startCapture(
with: device,
format: format,
fps: Self.preferredFPS(for: format)
)
}
}
engine.callVideoTracksDidChange(self)
}
private func completePreparation(_ result: Result<Void, Error>) {
isPreparingPeerConnection = false
let callbacks = prepareCallbacks
prepareCallbacks.removeAll()
callbacks.forEach { $0(result) }
}
private func receive(_ action: LumaJingleSession.Action) {
guard let peerConnection else { return }
switch action {
case .transportAdd(let candidate, let contentName):
guard remoteSDP != nil else {
remoteCandidates.append((candidate, contentName))
return
}
addRemoteCandidate(candidate, contentName: contentName, to: peerConnection)
case .contentSet(let sdp):
applyRemoteSDP(sdp, to: peerConnection)
case .contentApply(let action, let diff):
guard let remoteSDP else { return }
applyRemoteSDP(remoteSDP.applyDiff(action: action, diff: diff), to: peerConnection)
case .sessionInfo:
break
}
}
private func applyRemoteSDP(_ sdp: SDP, to peerConnection: RTCPeerConnection) {
let normalizedSDP: SDP
do {
normalizedSDP = try normalizeInitialRemoteAnswer(sdp)
} catch {
engine.callDidFail(self, error: error)
return
}
let type: RTCSdpType = direction == .incoming ? .offer : .answer
let text = normalizedSDP.toString(
withSid: webRTCSID,
localRole: session.role,
direction: .incoming
)
peerConnection.setRemoteDescription(
RTCSessionDescription(type: type, sdp: text)
) { [weak self] error in
guard let self else { return }
self.engine.performOnQueue {
if let error {
#if DEBUG
print(
"Luma WebRTC: setRemoteDescription failed: \(error.localizedDescription)"
)
#endif
self.engine.callDidFail(self, error: error)
return
}
self.remoteSDP = normalizedSDP
let candidates = self.remoteCandidates
self.remoteCandidates.removeAll()
candidates.forEach {
self.addRemoteCandidate($0.0, contentName: $0.1, to: peerConnection)
}
// For an outgoing call session-initiate may already be
// acknowledged. Flush only now, after session-accept supplied
// both the answer and the exact full JID of its owner.
self.sendLocalCandidates()
if peerConnection.signalingState == .haveRemoteOffer {
self.generateDescription(type: .answer, peerConnection: peerConnection) {
result in
switch result {
case .success(let local):
Task {
do {
try await self.session.accept(
contents: local.contents,
bundle: local.bundle
)
self.engine.performOnQueue {
self.initialDescriptionWasSignaled = true
self.sendLocalCandidates()
}
} catch {
self.engine.callDidFail(self, error: error)
}
}
case .failure(let error):
self.engine.callDidFail(self, error: error)
}
}
}
}
}
}
private func generateDescription(
type: RTCSdpType,
peerConnection: RTCPeerConnection,
completion: @escaping (Result<SDP, Error>) -> Void
) {
let mediaConstraints = RTCMediaConstraints(
mandatoryConstraints: nil,
optionalConstraints: nil
)
let handler: (RTCSessionDescription?, Error?) -> Void = { [weak self] description, error in
guard let self else { return }
guard let description else {
self.engine.performOnQueue {
completion(.failure(error ?? LumaCallError.sdpGenerationFailed))
}
return
}
peerConnection.setLocalDescription(description) { error in
self.engine.performOnQueue {
if let error {
completion(.failure(error))
return
}
// Martin 3.2.4 assumes that libwebrtc terminates SDP with
// CRLF. Normalize its input first, then repair the FID
// source metadata independently below. The latter is
// intentionally kept even with normalized line endings:
// WebRTC M150 can still expose a shortened final RTX msid.
let martinSDP = CallSDPNormalization.martinParseInput(
description.sdp
)
guard
let (sdp, _) = SDP.parse(
sdpString: martinSDP,
creatorProvider: self.session.contentCreator(of:),
localRole: self.session.role
)
else {
completion(.failure(LumaCallError.sdpGenerationFailed))
return
}
let repaired = CallSDPCompatibility.repairLocalFIDMSIDs(in: sdp)
let fidStatus = CallSDPCompatibility.fidGroupStatus(in: repaired.sdp)
guard fidStatus.inconsistent == 0 else {
completion(.failure(LumaCallError.incompatibleLocalDescription))
return
}
#if DEBUG
if fidStatus.total > 0 {
print(
"Luma SDP: verified \(fidStatus.total) FID group(s); "
+ "repaired \(repaired.repairedCount) source msid(s)."
)
}
#endif
self.localSDP = repaired.sdp
completion(.success(repaired.sdp))
}
}
}
if type == .offer {
peerConnection.offer(for: mediaConstraints, completionHandler: handler)
} else {
peerConnection.answer(for: mediaConstraints, completionHandler: handler)
}
}
private func sendLocalCandidates() {
// setLocalDescription starts ICE before the corresponding Jingle stanza
// is acknowledged. With JMI, session-initiate can be addressed to a
// bare JID while only one resource accepts it. Keep candidates queued
// until the remote SDP arrives from that concrete full JID; otherwise
// Prosody can route transport-info to another resource, which correctly
// responds with item-not-found for the SID.
guard
CallCandidateDispatchGate.canSend(
initialDescriptionWasSignaled: initialDescriptionWasSignaled,
hasLocalDescription: localSDP != nil,
hasRemoteDescription: remoteSDP != nil,
hasFullPeerJID: session.jid.resource != nil
), let localSDP
else { return }
let candidates = localCandidates
localCandidates.removeAll()
#if DEBUG
if !candidates.isEmpty {
print(
"Luma Jingle: sending \(candidates.count) ICE candidate(s) "
+ "to \(session.jid.stringValue) sid=\(session.sid)."
)
}
#endif
for candidate in candidates {
let line = candidate.sdp.hasPrefix("a=") ? candidate.sdp : "a=\(candidate.sdp)"
guard let jingleCandidate = Jingle.Transport.ICEUDPTransport.Candidate(fromSDP: line),
let contentName = candidate.sdpMid,
let content = localSDP.contents.first(where: { $0.name == contentName }),
let transport = content.transports.first(where: {
$0 is Jingle.Transport.ICEUDPTransport
}) as? Jingle.Transport.ICEUDPTransport
else { continue }
_ = session.transportInfo(
contentName: contentName,
transport: Jingle.Transport.ICEUDPTransport(
pwd: transport.pwd,
ufrag: transport.ufrag,
candidates: [jingleCandidate]
)
)
}
}
private func addRemoteCandidate(
_ candidate: Jingle.Transport.ICEUDPTransport.Candidate,
contentName: String,
to peerConnection: RTCPeerConnection
) {
let index = remoteSDP?.contents.firstIndex(where: { $0.name == contentName }) ?? 0
let candidateSDP = CallSDPOrdering.webRTCCandidateLine(candidate.toSDP())
peerConnection.add(
RTCIceCandidate(
sdp: candidateSDP,
sdpMLineIndex: Int32(index),
sdpMid: contentName
),
completionHandler: { error in
#if DEBUG
if let error {
print(
"Luma WebRTC: remote ICE candidate rejected: \(error.localizedDescription)"
)
}
#endif
}
)
}
private func normalizeInitialRemoteAnswer(_ sdp: SDP) throws -> SDP {
guard direction == .outgoing, remoteSDP == nil, let localSDP else {
return sdp
}
let expectedNames = localSDP.contents.map(\.name)
let receivedNames = sdp.contents.map(\.name)
guard
let indices = CallSDPOrdering.answerIndices(
localOrder: expectedNames,
remoteOrder: receivedNames
)
else {
throw LumaCallError.incompatibleRemoteDescription
}
let normalizedBundle: [String]?
if let bundle = sdp.bundle {
let bundleNames = Set(bundle)
guard bundleNames.count == bundle.count,
bundleNames.isSubset(of: Set(receivedNames))
else {
throw LumaCallError.incompatibleRemoteDescription
}
normalizedBundle = expectedNames.filter { bundleNames.contains($0) }
} else {
normalizedBundle = nil
}
guard indices != Array(sdp.contents.indices) || normalizedBundle != sdp.bundle else {
return sdp
}
let orderedContents = indices.map { sdp.contents[$0] }
#if DEBUG
print("Luma WebRTC: reordered session-accept contents to match the local offer.")
#endif
return SDP(contents: orderedContents, bundle: normalizedBundle)
}
private func cancelScheduledConnectionFailure() {
connectionFailureWorkItem?.cancel()
connectionFailureWorkItem = nil
}
private func scheduleConnectionFailure(after delay: TimeInterval) {
connectionFailureWorkItem?.cancel()
let item = DispatchWorkItem { [weak self] in
guard let self else { return }
self.connectionFailureWorkItem = nil
self.engine.callDidLoseConnection(self)
}
connectionFailureWorkItem = item
engine.performOnQueue(after: delay, execute: item)
}
private static func preferredCamera() -> AVCaptureDevice? {
RTCCameraVideoCapturer.captureDevices().first(where: { $0.position == .front })
?? RTCCameraVideoCapturer.captureDevices().first
}
private static func preferredFormat(for device: AVCaptureDevice) -> AVCaptureDevice.Format? {
RTCCameraVideoCapturer.supportedFormats(for: device).min { lhs, rhs in
let left = CMVideoFormatDescriptionGetDimensions(lhs.formatDescription)
let right = CMVideoFormatDescriptionGetDimensions(rhs.formatDescription)
let leftDistance = abs(Int(left.width) - 1_280) + abs(Int(left.height) - 720)
let rightDistance = abs(Int(right.width) - 1_280) + abs(Int(right.height) - 720)
return leftDistance < rightDistance
}
}
private static func preferredFPS(for format: AVCaptureDevice.Format) -> Int {
let maximum = format.videoSupportedFrameRateRanges.map(\.maxFrameRate).max() ?? 30
return max(15, min(30, Int(maximum.rounded())))
}
// MARK: RTCPeerConnectionDelegate
func peerConnection(
_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState
) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) {
guard let track = stream.videoTracks.first else { return }
engine.performOnQueue {
self.remoteVideoTrack = track
self.engine.callVideoTracksDidChange(self)
}
}
func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) {}
func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) {}
func peerConnection(
_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState
) {
engine.performOnQueue {
#if DEBUG
print("Luma WebRTC: ICE state changed to \(String(describing: newState)).")
#endif
switch newState {
case .connected, .completed:
self.cancelScheduledConnectionFailure()
self.engine.callDidConnect(self)
case .checking:
self.cancelScheduledConnectionFailure()
case .disconnected:
self.scheduleConnectionFailure(after: 12)
case .failed:
// A late TURN or video-component candidate can still move ICE
// back to checking. Keep the call alive during trickle ICE.
self.scheduleConnectionFailure(after: 8)
case .closed:
self.cancelScheduledConnectionFailure()
self.engine.callDidLoseConnection(self)
default:
break
}
}
}
func peerConnection(
_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState
) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate)
{
engine.performOnQueue {
self.localCandidates.append(candidate)
self.sendLocalCandidates()
}
}
func peerConnection(
_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]
) {}
func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) {}
func peerConnection(
_ peerConnection: RTCPeerConnection,
didAdd rtpReceiver: RTCRtpReceiver,
streams mediaStreams: [RTCMediaStream]
) {
guard let track = rtpReceiver.track as? RTCVideoTrack else { return }
engine.performOnQueue {
self.remoteVideoTrack = track
self.engine.callVideoTracksDidChange(self)
}
}
func peerConnection(_ peerConnection: RTCPeerConnection, didRemove rtpReceiver: RTCRtpReceiver)
{
guard let track = rtpReceiver.track as? RTCVideoTrack else { return }
engine.performOnQueue {
guard self.remoteVideoTrack === track else { return }
self.remoteVideoTrack = nil
self.engine.callVideoTracksDidChange(self)
}
}
}
// MARK: - Queue helpers kept fileprivate for the live call object
extension LumaCallEngine {
fileprivate func performOnQueue(_ action: @escaping () -> Void) {
queue.async(execute: action)
}
fileprivate func performOnQueue(after delay: TimeInterval, execute item: DispatchWorkItem) {
queue.asyncAfter(deadline: .now() + delay, execute: item)
}
fileprivate func performAttachedClient() -> XMPPClient? {
performSync { client }
}
}
extension ExternalServiceDiscoveryModule.Service {
fileprivate var rtcIceServer: RTCIceServer? {
let normalizedType = type.lowercased()
let normalizedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard ["stun", "stuns", "turn", "turns"].contains(normalizedType) else { return nil }
guard !normalizedHost.isEmpty,
normalizedHost.rangeOfCharacter(from: .whitespacesAndNewlines) == nil,
!normalizedHost.contains("/"),
!normalizedHost.contains("?"),
!normalizedHost.contains("#")
else { return nil }
guard !normalizedType.hasSuffix("s") || transport == nil || transport == .tcp else {
return nil
}
if normalizedType.hasPrefix("turn") {
guard let username, !username.isEmpty,
let password, !password.isEmpty
else { return nil }
}
if let expires, expires <= Date() { return nil }
let escapedHost =
normalizedHost.contains(":") && !normalizedHost.hasPrefix("[")
? "[\(normalizedHost)]"
: normalizedHost
var url = "\(normalizedType):\(escapedHost)"
if let port { url += ":\(port)" }
// RFC 7064 STUN URLs do not have a transport query. The query belongs
// to TURN URLs; appending it to STUN makes newer libwebrtc reject the
// entire RTCConfiguration.
if normalizedType.hasPrefix("turn"), let transport {
url += "?transport=\(transport.rawValue)"
}
return RTCIceServer(
urlStrings: [url],
username: normalizedType.hasPrefix("turn") ? username : nil,
credential: normalizedType.hasPrefix("turn") ? password : nil,
tlsCertPolicy: .secure
)
}
}
enum CallSDPOrdering {
static func answerIndices(localOrder: [String], remoteOrder: [String]) -> [Int]? {
guard localOrder.count == remoteOrder.count,
Set(localOrder).count == localOrder.count,
Set(remoteOrder).count == remoteOrder.count,
Set(localOrder) == Set(remoteOrder)
else { return nil }
let remoteIndices = Dictionary(
uniqueKeysWithValues: remoteOrder.enumerated().map { ($0.element, $0.offset) }
)
return localOrder.compactMap { remoteIndices[$0] }
}
static func webRTCCandidateLine(_ jingleSDP: String) -> String {
jingleSDP.hasPrefix("a=") ? String(jingleSDP.dropFirst(2)) : jingleSDP
}
}
enum CallSDPNormalization {
static func martinParseInput(_ webRTCSDP: String) -> String {
let lfNormalized =
webRTCSDP
.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
var lines = lfNormalized.components(separatedBy: "\n")
while lines.last?.isEmpty == true {
lines.removeLast()
}
return lines.joined(separator: "\r\n") + "\r\n"
}
}
enum CallSDPCompatibility {
struct SourceGroup: Equatable {
let semantics: String
let sources: [String]
}
/// Every SSRC in an FID group represents the same media track (the later
/// sources carry retransmissions), so their msid values must be identical.
/// Prefer the longest value when a converter shortened one at the end; for
/// equal lengths this naturally keeps the primary source's value.
static func canonicalFIDMSIDs(
groups: [SourceGroup],
msids: [String: String]
) -> [String: String] {
var result = msids
for group in groups where group.semantics.uppercased() == "FID" {
guard group.sources.count >= 2 else { continue }
let candidates = group.sources.compactMap { result[$0] }
guard let canonical = candidates.max(by: { $0.count < $1.count }) else {
continue
}
group.sources.forEach { result[$0] = canonical }
}
return result
}
static func repairLocalFIDMSIDs(in sdp: SDP) -> (sdp: SDP, repairedCount: Int) {
var repairedCount = 0
let contents = sdp.contents.map { content -> Jingle.Content in
guard let description = content.description as? Jingle.RTP.Description else {
return content
}
var currentMSIDs: [String: String] = [:]
for source in description.ssrcs {
guard
let value = source.parameters.first(where: {
$0.key.lowercased() == "msid"
})?.value, !value.isEmpty
else { continue }
currentMSIDs[source.ssrc] = value
}
let desiredMSIDs = canonicalFIDMSIDs(
groups: description.ssrcGroups.map {
SourceGroup(semantics: $0.semantics, sources: $0.sources)
},
msids: currentMSIDs
)
var changed = false
let sources = description.ssrcs.map { source -> Jingle.RTP.Description.SSRC in
guard let desired = desiredMSIDs[source.ssrc],
currentMSIDs[source.ssrc] != desired
else {
return source
}
changed = true
repairedCount += 1
let parameters =
source.parameters.filter {
$0.key.lowercased() != "msid"
} + [
Jingle.RTP.Description.SSRC.Parameter(
key: "msid",
value: desired
)
]
return Jingle.RTP.Description.SSRC(
ssrc: source.ssrc,
parameters: parameters
)
}
guard changed else { return content }
let repairedDescription = Jingle.RTP.Description(
media: description.media,
ssrc: description.ssrc,
payloads: description.payloads,
bandwidth: description.bandwidth,
encryption: description.encryption,
rtcpMux: description.rtcpMux,
ssrcs: sources,
ssrcGroups: description.ssrcGroups,
hdrExts: description.hdrExts
)
return Jingle.Content(
name: content.name,
creator: content.creator,
senders: content.senders,
description: repairedDescription,
transports: content.transports
)
}
guard repairedCount > 0 else { return (sdp, 0) }
return (SDP(contents: contents, bundle: sdp.bundle), repairedCount)
}
static func fidGroupStatus(in sdp: SDP) -> (total: Int, inconsistent: Int) {
var total = 0
var inconsistent = 0
for content in sdp.contents {
guard let description = content.description as? Jingle.RTP.Description else {
continue
}
let msids = Dictionary(
uniqueKeysWithValues: description.ssrcs.compactMap { source in
source.parameters.first(where: {
$0.key.lowercased() == "msid"
})?.value.map { (source.ssrc, $0) }
}
)
for group in description.ssrcGroups where group.semantics.uppercased() == "FID" {
total += 1
let values = group.sources.compactMap { msids[$0] }
if values.count != group.sources.count || Set(values).count != 1 {
inconsistent += 1
}
}
}
return (total, inconsistent)
}
}
enum CallCandidateDispatchGate {
static func canSend(
initialDescriptionWasSignaled: Bool,
hasLocalDescription: Bool,
hasRemoteDescription: Bool,
hasFullPeerJID: Bool
) -> Bool {
initialDescriptionWasSignaled
&& hasLocalDescription
&& hasRemoteDescription
&& hasFullPeerJID
}
}
private enum LumaCallError: LocalizedError {
case callAlreadyActive
case noIncomingCall
case notConnected
case contactOffline
case unsupportedByContact
case mediaEngineUnavailable
case incompatibleLocalDescription
case incompatibleRemoteDescription
case sdpGenerationFailed
case connectionLost
case timedOut
var errorDescription: String? {
switch self {
case .callAlreadyActive:
return "Сначала завершите текущий звонок."
case .noIncomingCall:
return "Входящий звонок уже завершён."
case .notConnected:
return "Для звонка нужно активное XMPP-соединение."
case .contactOffline:
return "Контакт сейчас не в сети."
case .unsupportedByContact:
return "Устройства контакта не объявили поддержку совместимых Jingle-звонков."
case .mediaEngineUnavailable:
return
"WebRTC не смог создать соединение даже без STUN/TURN. Очистите сборку и переустановите приложение."
case .incompatibleLocalDescription:
return
"WebRTC сформировал несовместимое описание видеопотока. Повторите звонок после перезапуска приложения."
case .incompatibleRemoteDescription:
return
"Ответ на видеозвонок содержит другой набор аудио/видео потоков. Обновите клиент собеседника или повторите аудиозвонок."
case .sdpGenerationFailed:
return "Не удалось согласовать параметры аудио/видео."
case .connectionLost:
return "Соединение звонка потеряно. Проверьте TURN/STUN на сервере."
case .timedOut:
return "Контакт не ответил на звонок."
}
}
}