import AVFAudio import CallKit import Flutter import PushKit import UIKit import UserNotifications import WebRTC import YandexLoginSDK import flutter_callkit_incoming import flutter_foreground_task @main @objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate { private var voipRegistry: PKPushRegistry? private var voipDiagnosticsChannel: FlutterMethodChannel? private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics" private let pendingAcceptedVoipCallKey = "EatvioPendingAcceptedVoipCall" private let pendingAcceptedAtKey = "eatvioAcceptedAt" private let pendingAcceptedCallTtl: TimeInterval = 120 private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics" private let callAudioSessionChannelName = "com.eatvioapp/call_audio_session" private let appUrlScheme = "eatvio" /// CallKit is only the ringing UI. After accept the in-app call continues, /// so the audio session must be handed back to WebRTC when CallKit deactivates. private var resumeAudioAfterCallKitEnds = false /// The push handler and the Flutter channel touch this map on different /// queues. The push writes the id before CallKit has the call, so the /// in-app path can see it and not report the same call again. private var reservedIncomingCallIds: [String: Date] = [:] private let incomingCallClaimLock = NSLock() private let incomingCallClaimTtl: TimeInterval = 90 private let voipPushQueue = DispatchQueue(label: "com.eatvioapp.voip") private var voipCallReporterStorage: VoipCallReporter? private var audioRestartGeneration = 0 private var voipCallReporter: VoipCallReporter { if let voipCallReporterStorage { return voipCallReporterStorage } let reporter = VoipCallReporter() voipCallReporterStorage = reporter return reporter } override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) SwiftFlutterForegroundTaskPlugin.setPluginRegistrantCallback { registry in GeneratedPluginRegistrant.register(with: registry) } // Not the main queue. Flutter startup blocks main, and PushKit aborts the // process if this push is not reported before that returns. voipRegistry = PKPushRegistry(queue: voipPushQueue) voipRegistry?.delegate = self voipRegistry?.desiredPushTypes = [PKPushType.voIP] configureWebRTCAudioSession() if #available(iOS 10.0, *) { UNUserNotificationCenter.current().delegate = self } let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions) // WebRTC must not activate the session itself. CallKit and the in-app // call both own AVAudioSession, and a second activation drops the mic. let rtcSession = RTCAudioSession.sharedInstance() rtcSession.useManualAudio = true rtcSession.isAudioEnabled = false registerCallChannels(from: window?.rootViewController as? FlutterViewController) return launched } func registerCallChannels(from controller: FlutterViewController?) { guard voipDiagnosticsChannel == nil else { return } let flutterController = controller ?? flutterViewControllerFromScenes() guard let flutterController else { return } let channel = FlutterMethodChannel( name: voipDiagnosticsChannelName, binaryMessenger: flutterController.binaryMessenger ) voipDiagnosticsChannel = channel channel.setMethodCallHandler { [weak self] call, result in guard let self else { result(nil) return } switch call.method { case "drainVoipDiagnostics": result(self.drainVoipDiagnostics()) case "drainPendingAcceptedVoipCall": result(self.drainPendingAcceptedVoipCall()) case "isIncomingCallClaimed": result(self.isIncomingCallClaimed(self.incomingCallId(from: call.arguments))) case "reserveIncomingCall": result(self.reserveIncomingCall(self.incomingCallId(from: call.arguments))) default: result(FlutterMethodNotImplemented) } } let callAudioChannel = FlutterMethodChannel( name: callAudioSessionChannelName, binaryMessenger: flutterController.binaryMessenger ) callAudioChannel.setMethodCallHandler { [weak self] call, result in guard let self else { result(nil) return } switch call.method { case "activate": self.activateInAppCallAudioSession() result(nil) case "restart": self.restartInAppCallAudioUnit() result(nil) case "deactivate": self.deactivateInAppCallAudioSession() result(nil) default: result(FlutterMethodNotImplemented) } } } private func flutterViewControllerFromScenes() -> FlutterViewController? { for scene in UIApplication.shared.connectedScenes { guard let windowScene = scene as? UIWindowScene else { continue } for sceneWindow in windowScene.windows { if let controller = sceneWindow.rootViewController as? FlutterViewController { return controller } } } return nil } @available(iOS 8.0, *) override func application( _ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void ) -> Bool { if YXLSdk.shared.processUserActivity(userActivity) { return true } return super.application( application, continue: userActivity, restorationHandler: restorationHandler ) } override public func application( _ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:] ) -> Bool { handleOpenURL( url, sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String ) } @discardableResult func handleOpenURL(_ url: URL, sourceApplication: String?) -> Bool { if url.scheme == appUrlScheme { let components = URLComponents(url: url, resolvingAgainstBaseURL: false) let queryItems = components?.queryItems ?? [] let conversationUuid = queryItems.first { $0.name == "conversationUuid" }?.value? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let callKitId = queryItems.first { $0.name == "callKitId" }?.value? .trimmingCharacters(in: .whitespacesAndNewlines) recordVoipDiagnostic( "accepted_call_url_opened", data: [ "callKitId": callKitId ?? "", "hasCallKitId": callKitId != nil, "conversationUuid": conversationUuid, "url": url.absoluteString, ] ) if let callKitId, !callKitId.isEmpty, !conversationUuid.isEmpty { voipDiagnosticsChannel?.invokeMethod( "acceptedVoipCall", arguments: [ "id": callKitId, "handle": conversationUuid, "extra": [ "conversationUuid": conversationUuid ], ] ) } return true } return YXLSdk.shared.handleOpen( url, sourceApplication: sourceApplication ) } /// Same derivation as [VideoCallKitId.iosUuid]: one CallKit UUID per message, /// so the next call in this chat is not rejected as a duplicate UUID. private func derivedCallKitId(conversationUuid: String, messageId: UInt64?) -> String? { guard let messageId, messageId > 0 else { return nil } let pattern = #"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-)[0-9a-fA-F]{12}$"# guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } let range = NSRange(conversationUuid.startIndex..., in: conversationUuid) guard let match = regex.firstMatch(in: conversationUuid, range: range), let prefixRange = Range(match.range(at: 1), in: conversationUuid) else { return nil } let suffix = String(format: "%012llx", messageId) guard suffix.count == 12 else { return nil } return (String(conversationUuid[prefixRange]) + suffix).lowercased() } private func voipMessageId(_ value: Any?) -> UInt64? { if let number = value as? NSNumber { let parsed = number.uint64Value return parsed > 0 ? parsed : nil } if let text = value as? String { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard let parsed = UInt64(trimmed), parsed > 0 else { return nil } return parsed } return nil } private func voipBool(_ value: Any?) -> Bool? { if let bool = value as? Bool { return bool } if let number = value as? NSNumber { return number.intValue != 0 } if let text = value as? String { switch text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { case "1", "true", "yes": return true case "0", "false", "no": return false default: return nil } } return nil } private func conversationUuid(fromActionUrl actionUrl: String?) -> String? { guard let actionUrl = actionUrl, let components = URLComponents(string: actionUrl) else { return nil } let queryItems = components.queryItems ?? [] for key: String in ["uuid", "conversationUuid", "conversation_uuid"] { if let value = queryItems.first(where: { $0.name == key })?.value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return value.trimmingCharacters(in: .whitespacesAndNewlines) } } let segments = components.path.split(separator: "/").map(String.init) for index in segments.indices where segments[index] == "video-call" { let nextIndex = segments.index(after: index) if segments.indices.contains(nextIndex), !segments[nextIndex].isEmpty { return segments[nextIndex] } } return nil } private func conversationUuid(fromCall call: Call) -> String? { let extra = call.data.extra as? [String: Any] ?? [:] for key in ["conversationUuid", "conversation_uuid", "conversation_id", "chat_uuid", "uuid"] { if let value = extra[key] as? String { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return trimmed } } } let handle = call.data.handle.trimmingCharacters(in: .whitespacesAndNewlines) if !handle.isEmpty { return handle } return nil } private func storePendingAcceptedCall(_ call: Call) { var payload = sanitizedDiagnosticValue(call.data.toJSON()) as? [String: Any] ?? [:] payload[pendingAcceptedAtKey] = Date().timeIntervalSince1970 UserDefaults.standard.set(payload, forKey: pendingAcceptedVoipCallKey) } /// An accept that Flutter never consumed (the process died before the /// scene opened) must not open the call screen on a later launch: that /// screen requests a token, and the token request rings the other person. private func drainPendingAcceptedVoipCall() -> [String: Any]? { let defaults = UserDefaults.standard guard var payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else { return nil } defaults.removeObject(forKey: pendingAcceptedVoipCallKey) let acceptedAt = (payload.removeValue(forKey: pendingAcceptedAtKey) as? NSNumber)?.doubleValue if let acceptedAt, Date().timeIntervalSince1970 - acceptedAt > pendingAcceptedCallTtl { recordVoipDiagnostic( "pending_accepted_call_expired", data: [ "callKitId": payload["id"] as? String ?? "", "ageSeconds": Date().timeIntervalSince1970 - acceptedAt, ] ) return nil } return payload } private func bringAppToForeground() { DispatchQueue.main.async { for scene in UIApplication.shared.connectedScenes { guard let windowScene = scene as? UIWindowScene else { continue } for window in windowScene.windows { window.makeKeyAndVisible() } } self.window?.makeKeyAndVisible() } } private func openAppForAcceptedCall(_ call: Call) { guard let conversationUuid = conversationUuid(fromCall: call), !conversationUuid.isEmpty else { recordVoipDiagnostic( "accepted_call_missing_conversation_uuid", data: ["callKitId": call.data.uuid] ) return } var components = URLComponents() components.scheme = appUrlScheme components.host = "video-call" components.queryItems = [ URLQueryItem(name: "callKitId", value: call.data.uuid), URLQueryItem(name: "conversationUuid", value: conversationUuid) ] guard let url = components.url else { recordVoipDiagnostic( "accepted_call_url_build_failed", data: [ "callKitId": call.data.uuid, "conversationUuid": conversationUuid, ] ) return } recordVoipDiagnostic( "accepted_call_open_app_requested", data: [ "callKitId": call.data.uuid, "conversationUuid": conversationUuid, "url": url.absoluteString, "appState": UIApplication.shared.applicationState.rawValue, ] ) bringAppToForeground() UIApplication.shared.open(url, options: [:]) { [weak self] success in self?.recordVoipDiagnostic( success ? "accepted_call_open_app_succeeded" : "accepted_call_open_app_failed", data: [ "callKitId": call.data.uuid, "conversationUuid": conversationUuid, "url": url.absoluteString, ] ) } } private func sanitizedDiagnosticValue(_ value: Any?) -> Any { guard let value = value else { return "" } if let string = value as? String { return string } if let number = value as? NSNumber { return number } if let bool = value as? Bool { return bool } if let dictionary = value as? [AnyHashable: Any] { return dictionary.reduce(into: [String: Any]()) { result, entry in result[String(describing: entry.key)] = sanitizedDiagnosticValue(entry.value) } } if let array = value as? [Any] { return array.map { sanitizedDiagnosticValue($0) } } return String(describing: value) } private func recordVoipDiagnostic(_ name: String, data: [String: Any]) { let defaults = UserDefaults.standard var events = defaults.array(forKey: voipDiagnosticsStorageKey) as? [[String: Any]] ?? [] var event: [String: Any] = [ "name": name, "timestamp": ISO8601DateFormatter().string(from: Date()), "appState": UIApplication.shared.applicationState.rawValue, ] event["data"] = sanitizedDiagnosticValue(data) events.append(event) if events.count > 20 { events = Array(events.suffix(20)) } defaults.set(events, forKey: voipDiagnosticsStorageKey) DispatchQueue.main.async { [weak self] in self?.voipDiagnosticsChannel?.invokeMethod("voipDiagnosticsUpdated", arguments: nil) } } private func drainVoipDiagnostics() -> [[String: Any]] { let defaults = UserDefaults.standard let events = defaults.array(forKey: voipDiagnosticsStorageKey) as? [[String: Any]] ?? [] defaults.removeObject(forKey: voipDiagnosticsStorageKey) return events } private func isEndedVideoCallMarker(_ marker: String) -> Bool { if marker == "video_call_ended" || marker == "video_call_end" { return true } return marker.contains("video_call") && (marker.contains("ended") || marker.hasSuffix("_end") || marker.hasSuffix("_cancel")) } private func incomingCallId(from arguments: Any?) -> String { let raw: String? if let id = arguments as? String { raw = id } else if let map = arguments as? [String: Any] { raw = map["id"] as? String } else { raw = nil } return raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" } /// Caller must hold `incomingCallClaimLock`. private func pruneIncomingCallClaims(now: Date = Date()) { reservedIncomingCallIds = reservedIncomingCallIds.filter { now.timeIntervalSince($0.value) < incomingCallClaimTtl } } private func activeIncomingCallIds() -> Set { let calls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? [] return Set(calls.compactMap { ($0["id"] as? String)?.lowercased() }) } private func isIncomingCallClaimed(_ id: String) -> Bool { let normalized = id.lowercased() guard !normalized.isEmpty else { return false } let reserved = incomingCallClaimLock.withLock { () -> Bool in pruneIncomingCallClaims() return reservedIncomingCallIds[normalized] != nil } if reserved { return true } return activeIncomingCallIds().contains(normalized) } /// Returns false when this id is already reserved. The VoIP push still /// reports the call; this only stops the in-app path from reporting it too. private func reserveIncomingCall(_ id: String) -> Bool { let normalized = id.lowercased() guard !normalized.isEmpty else { return false } return incomingCallClaimLock.withLock { pruneIncomingCallClaims() if reservedIncomingCallIds[normalized] != nil { return false } reservedIncomingCallIds[normalized] = Date() return true } } private func releaseIncomingCallClaim(_ id: String) { incomingCallClaimLock.withLock { _ = reservedIncomingCallIds.removeValue(forKey: id.lowercased()) } } /// An ended VoIP push has no message id, so its CallKit id is the /// conversation uuid, not the ringing call. End that conversation only. /// The first endCall while the push flag is set closes the call this push /// just reported; the next ones close the call that is actually ringing. private func endCallsForEndedPush(reportedId: String, conversationUuid: String) { guard let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else { releaseIncomingCallClaim(reportedId) return } let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines) var ids: [String] = [] func append(_ id: String) { let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalized.isEmpty else { return } if ids.contains(where: { $0.caseInsensitiveCompare(normalized) == .orderedSame }) { return } ids.append(normalized) } append(reportedId) for call in plugin.activeCalls() { let callId = (call["id"] as? String) ?? "" let handle = (call["handle"] as? String) ?? "" let extraUuid = extraConversationUuid(call["extra"]) let sameId = callId.caseInsensitiveCompare(reportedId) == .orderedSame let sameConversation = !conversation.isEmpty && ( handle.caseInsensitiveCompare(conversation) == .orderedSame || extraUuid.caseInsensitiveCompare(conversation) == .orderedSame ) if sameId || sameConversation { append(callId) } } for id in ids { let endData = flutter_callkit_incoming.Data( id: id, nameCaller: "Итвио", handle: conversation, type: 1 ) // The plugin sends this payload with its "ended" event. Dart uses the // marker to tell a remote end from a decline by the user. var endExtra: [String: Any] = ["endedPush": true] if !conversation.isEmpty { endExtra["conversationUuid"] = conversation } endData.extra = endExtra as NSDictionary plugin.endCall(endData) releaseIncomingCallClaim(id) } } private func activeCallId(forConversation conversationUuid: String) -> String? { let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines) guard !conversation.isEmpty else { return nil } let calls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? [] for call in calls { let callId = (call["id"] as? String) ?? "" guard !callId.isEmpty else { continue } let handle = (call["handle"] as? String) ?? "" let extraUuid = extraConversationUuid(call["extra"]) if handle.caseInsensitiveCompare(conversation) == .orderedSame || extraUuid.caseInsensitiveCompare(conversation) == .orderedSame { return callId } } return nil } private func extraConversationUuid(_ extra: Any?) -> String { let map: [String: Any] if let typed = extra as? [String: Any] { map = typed } else if let object = extra as? NSDictionary { var converted: [String: Any] = [:] for (key, value) in object { if let key = key as? String { converted[key] = value } } map = converted } else { return "" } for key in ["conversationUuid", "conversation_uuid"] { if let value = map[key] as? String { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return trimmed } } } return "" } func pushRegistry( _ registry: PKPushRegistry, didUpdate credentials: PKPushCredentials, for type: PKPushType ) { let deviceToken = credentials.token.map { String(format: "%02x", $0) }.joined() DispatchQueue.main.async { SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(deviceToken) } } func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) { DispatchQueue.main.async { SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("") } } func pushRegistry( _ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void ) { guard type == .voIP else { completion() return } let dict = payload.dictionaryPayload let pushMarkers = [ dict["type"] as? String, dict["notification_type"] as? String, dict["event"] as? String, dict["action"] as? String, dict["push_type"] as? String, dict["call_type"] as? String, ] .compactMap { $0?.lowercased().replacingOccurrences(of: "-", with: "_") } let isEndedPush = pushMarkers.contains(where: { self.isEndedVideoCallMarker($0) }) if isEndedPush { recordVoipDiagnostic( "voip_end_or_cancel_received", data: [ "markers": pushMarkers, "payload": dict, ] ) } let rawNameCaller = (dict["nameCaller"] as? String) ?? (dict["counterpartName"] as? String) ?? (dict["counterpart_name"] as? String) ?? (dict["caller_name"] as? String) ?? (dict["callerName"] as? String) ?? (dict["caller"] as? String) ?? "" let nameCaller = rawNameCaller.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Итвио" : rawNameCaller.trimmingCharacters(in: .whitespacesAndNewlines) let conversationUuid = (dict["conversation_uuid"] as? String) ?? (dict["conversationUuid"] as? String) ?? (dict["conversation_id"] as? String) ?? (dict["chat_uuid"] as? String) ?? (dict["uuid"] as? String) ?? conversationUuid(fromActionUrl: dict["action_url"] as? String) ?? (dict["handle"] as? String) ?? "" let trimmedConversationUuid = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines) let parsedMessageId = voipMessageId(dict["message_id"]) let id: String let callKitIdSourceName: String if isEndedPush, let existing = activeCallId(forConversation: trimmedConversationUuid) { id = existing callKitIdSourceName = "active_conversation" } else if let derived = derivedCallKitId( conversationUuid: trimmedConversationUuid, messageId: parsedMessageId ) { id = derived callKitIdSourceName = "conversation_and_message" } else if UUID(uuidString: trimmedConversationUuid) != nil { id = trimmedConversationUuid.lowercased() callKitIdSourceName = "conversation" } else { id = UUID().uuidString.lowercased() callKitIdSourceName = "generated" } let isVideo = (dict["isVideo"] as? Bool) ?? (dict["is_video"] as? Bool) ?? true var extra: [String: Any] = [:] if !trimmedConversationUuid.isEmpty { extra["conversationUuid"] = trimmedConversationUuid } if let avatar = dict["caller_avatar"] as? String ?? dict["avatar"] as? String ?? dict["avatarUrl"] as? String ?? dict["avatar_url"] as? String ?? dict["counterpartAvatarUrl"] as? String ?? dict["counterpart_avatar_url"] as? String { extra["counterpartAvatarUrl"] = avatar } if let messageId = dict["message_id"] as? String { extra["messageId"] = messageId } if let roomName = dict["room_name"] as? String { extra["roomName"] = roomName } if let localName = dict["localParticipantName"] as? String ?? dict["local_participant_name"] as? String { extra["localParticipantName"] = localName } if let counterpartName = dict["counterpartName"] as? String { extra["counterpartName"] = counterpartName } else if let counterpartName = dict["counterpart_name"] as? String { extra["counterpartName"] = counterpartName } else if !nameCaller.isEmpty { extra["counterpartName"] = nameCaller } if let isConsultant = voipBool(dict["is_consultant"] ?? dict["isConsultant"]) { extra["counterpartIsConsultant"] = isConsultant } // Closing this report looks like a user decline. Dart must not tell the // backend, or the server sends another ended VoIP and the other phone // shows it as a new incoming call. if isEndedPush { extra["endedPush"] = true } // An ended push with no ringing call still has to be reported. That // report is closed at once, so it must not leave a trace anywhere. let isPhantomEndedReport = isEndedPush && callKitIdSourceName != "active_conversation" let data = flutter_callkit_incoming.Data( id: id, nameCaller: nameCaller, handle: trimmedConversationUuid.isEmpty ? nameCaller : trimmedConversationUuid, type: isVideo ? 1 : 0 ) data.appName = "Итвио" data.handleType = "generic" data.supportsVideo = true data.duration = 45000 data.extra = extra as NSDictionary data.missedNotificationSubtitle = "Пропущенный видеозвонок" data.missedNotificationCallbackText = "Перезвонить" data.isShowMissedCallNotification = !isEndedPush data.includesCallsInRecents = !isPhantomEndedReport // The plugin activates AVAudioSession itself on answer and again 1.2s // later. That drops the ringing UI and leaves one side silent. CallKit // and WebRTC own the session instead. data.configureAudioSession = false data.audioSessionActive = false data.audioSessionMode = "videoChat" // Do not set ringtonePath. The plugin stores that string as the ringtone // file name. "system_ringtone_default" is not in the app, so CallKit ends // the call at once and PushKit delivers the same push again. // Remember the id so the in-app socket path does not report it again. // The push itself must still be reported, or iOS kills the process. _ = reserveIncomingCall(id) NSLog( "[EatvioVoIP] report incoming call id=\(id) conversation=\(conversationUuid) state=\(UIApplication.shared.applicationState.rawValue) markers=\(pushMarkers.joined(separator: ",")) payload=\(dict)" ) recordVoipDiagnostic( "voip_incoming_received", data: [ "callKitId": id, "callKitIdSource": callKitIdSourceName, "conversationUuid": conversationUuid, "nameCaller": nameCaller, "isVideo": isVideo, "markers": pushMarkers, "payload": dict, ] ) reportVoipPushToCallKit( data, callKitId: id, callKitIdSourceName: callKitIdSourceName, conversationUuid: conversationUuid, endAfterReport: isEndedPush, endImmediately: isPhantomEndedReport, completion: completion ) } /// PushKit kills the app unless every VoIP push is reported to CallKit and /// its completion handler runs. Both happen here, including an "ended" push. /// /// `endImmediately` is for an ended push that matched no ringing call. The /// report is closed inside its own completion, before the incoming UI has a /// chance to show. An end transaction would take one more round trip and /// would also reach `onDecline`, which resets the in-app audio restart. private func reportVoipPushToCallKit( _ data: flutter_callkit_incoming.Data, callKitId: String, callKitIdSourceName: String, conversationUuid: String, endAfterReport: Bool, endImmediately: Bool, completion: @escaping () -> Void ) { let gate = VoipPushCompletion(completion) let finish = { self.voipPushQueue.async { gate.call() } } voipPushQueue.asyncAfter(deadline: .now() + 1.5) { finish() } let afterReport = { if endAfterReport, endImmediately, let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance { // Reason 6 is CXCallEndedReason.remoteEnded in the plugin. plugin.saveEndCall(callKitId, 6) } if endAfterReport { DispatchQueue.main.async { self.endCallsForEndedPush( reportedId: callKitId, conversationUuid: conversationUuid ) } } if let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance { let activeCalls = plugin.activeCalls() let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",") self.recordVoipDiagnostic( "callkit_report_completed", data: [ "callKitId": callKitId, "callKitIdSource": callKitIdSourceName, "conversationUuid": conversationUuid, "activeCallIds": activeIds, "activeCallsCount": activeCalls.count, "endAfterReport": endAfterReport, ] ) } finish() } if let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance { plugin.showCallkitIncoming(data, fromPushKit: true, completion: afterReport) return } NSLog("[EatvioVoIP] CallKit plugin missing id=\(callKitId)") recordVoipDiagnostic( "callkit_plugin_missing", data: [ "callKitId": callKitId, "conversationUuid": conversationUuid, ] ) guard let uuid = UUID(uuidString: data.uuid) else { finish() return } voipCallReporter.report( uuid: uuid, callerName: data.nameCaller, hasVideo: data.type > 0 ) { _ in if endAfterReport { self.voipCallReporter.provider.reportCall( with: uuid, endedAt: Date(), reason: .remoteEnded ) } afterReport() } } func onAccept(_ call: Call, _ action: CXAnswerCallAction) { resumeAudioAfterCallKitEnds = true prepareCallAudioCategory() storePendingAcceptedCall(call) recordVoipDiagnostic( "callkit_accept_native", data: [ "callKitId": call.data.uuid, "conversationUuid": conversationUuid(fromCall: call) ?? "", "data": call.data.toJSON(), ] ) voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: call.data.toJSON()) DispatchQueue.main.async { [weak self] in self?.bringAppToForeground() self?.openAppForAcceptedCall(call) } action.fulfill() } func onDecline(_ call: Call, _ action: CXEndCallAction) { resumeAudioAfterCallKitEnds = false audioRestartGeneration += 1 action.fulfill() } func onEnd(_ call: Call, _ action: CXEndCallAction) { action.fulfill() } func onTimeOut(_ call: Call) {} func didActivateAudioSession(_ audioSession: AVAudioSession) { let rtcSession = RTCAudioSession.sharedInstance() rtcSession.useManualAudio = true rtcSession.audioSessionDidActivate(audioSession) applyCallAudioConfiguration(active: false) rtcSession.isAudioEnabled = true } func didDeactivateAudioSession(_ audioSession: AVAudioSession) { let rtcSession = RTCAudioSession.sharedInstance() rtcSession.audioSessionDidDeactivate(audioSession) // The audio unit is stopped, but isAudioEnabled stays true. A later // assignment of true is then a no-op and the microphone never restarts. rtcSession.isAudioEnabled = false guard resumeAudioAfterCallKitEnds else { return } DispatchQueue.main.async { [weak self] in guard let self, self.resumeAudioAfterCallKitEnds else { return } self.restartInAppCallAudioUnit() } } func providerDidReset() { resumeAudioAfterCallKitEnds = false audioRestartGeneration += 1 } private func configureWebRTCAudioSession() { let configuration = callAudioConfiguration() RTCAudioSessionConfiguration.setWebRTC(configuration) } private func prepareCallAudioCategory() { applyCallAudioConfiguration(active: false) } private func activateInAppCallAudioSession() { let rtcSession = RTCAudioSession.sharedInstance() rtcSession.useManualAudio = true applyCallAudioConfiguration(active: true) // Starting the audio unit while the configuration lock is held deadlocks // the capture thread, so the mic stays silent. rtcSession.isAudioEnabled = true } /// Restarts capture after the previous audio graph has disconnected. /// Toggling the unit in the same turn makes voice processing throw. private func restartInAppCallAudioUnit() { audioRestartGeneration += 1 let generation = audioRestartGeneration let rtcSession = RTCAudioSession.sharedInstance() rtcSession.useManualAudio = true rtcSession.isAudioEnabled = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in guard let self, generation == self.audioRestartGeneration else { return } self.activateInAppCallAudioSession() } } private func applyCallAudioConfiguration(active: Bool) { let rtcSession = RTCAudioSession.sharedInstance() rtcSession.lockForConfiguration() defer { rtcSession.unlockForConfiguration() } do { try rtcSession.setConfiguration(callAudioConfiguration(), active: active) } catch { NSLog("[EatvioVoIP] apply call audio configuration failed: \(error)") } } private func deactivateInAppCallAudioSession() { resumeAudioAfterCallKitEnds = false audioRestartGeneration += 1 let rtcSession = RTCAudioSession.sharedInstance() rtcSession.isAudioEnabled = false rtcSession.lockForConfiguration() defer { rtcSession.unlockForConfiguration() } do { try rtcSession.setActive(false) } catch { NSLog("[EatvioVoIP] deactivate call audio session failed: \(error)") } } private func callAudioConfiguration() -> RTCAudioSessionConfiguration { let configuration = RTCAudioSessionConfiguration.webRTC() configuration.category = AVAudioSession.Category.playAndRecord.rawValue configuration.categoryOptions = [ .allowBluetooth, .defaultToSpeaker, ] // videoChat is the speakerphone mode for a video call. voiceChat keeps // the input on the receiver, so the mic is silent once audio goes to // the loudspeaker. configuration.mode = AVAudioSession.Mode.videoChat.rawValue // Voice processing rejects 44.1 kHz and throws from AVAudioEngine. configuration.sampleRate = 48000 configuration.ioBufferDuration = 0.02 configuration.inputNumberOfChannels = 1 configuration.outputNumberOfChannels = 1 return configuration } } /// Reports a VoIP push when the Flutter CallKit plugin is not registered yet. private final class VoipPushCompletion { private var finished = false private let completion: () -> Void init(_ completion: @escaping () -> Void) { self.completion = completion } func call() { if finished { return } finished = true completion() } } private final class VoipCallReporter: NSObject, CXProviderDelegate { let provider: CXProvider override init() { let configuration = CXProviderConfiguration(localizedName: "Итвио") configuration.supportsVideo = true configuration.maximumCallGroups = 1 configuration.maximumCallsPerCallGroup = 1 configuration.supportedHandleTypes = [.generic] provider = CXProvider(configuration: configuration) super.init() provider.setDelegate(self, queue: nil) } func report( uuid: UUID, callerName: String, hasVideo: Bool, completion: @escaping (Error?) -> Void ) { let update = CXCallUpdate() update.remoteHandle = CXHandle(type: .generic, value: callerName) update.localizedCallerName = callerName update.hasVideo = hasVideo provider.reportNewIncomingCall(with: uuid, update: update, completion: completion) } func providerDidReset(_ provider: CXProvider) {} }