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 var callAudioChannel: FlutterMethodChannel? private let appUrlScheme = "eatvio" /// True only if CallKit ended while the in-app call must keep audio. /// Incoming accept keeps CallKit up, so this stays false after answer. 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 /// Cancelled / declined server call ids. A late `video_call_started` with the /// same id must not show ringing again (same TTL as Android / Dart). private var tombstonedCallIds: [String: Date] = [:] /// CallKit ids closed by an ended VoIP push. `onDecline` sees the original /// ring, which has no `endedPush` marker, and must not POST /decline. private var remoteEndedCallKitIds = Set() private let remoteEndedCallLock = NSLock() private let callTombstoneTtl: TimeInterval = 120 private let voipPushQueue = DispatchQueue(label: "com.eatvioapp.voip") private var voipCallReporterStorage: VoipCallReporter? private var audioRestartGeneration = 0 /// CallKit owns AVAudioSession from answer until the CallKit call ends. /// WebRTC must not call setActive(true) on top of that session. private var callKitOwnsAudioSession = false 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 ) self.callAudioChannel = callAudioChannel 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 { var extra: [String: Any] = ["conversationUuid": conversationUuid] if let rawCallId = queryItems.first(where: { $0.name == "call_id" })?.value { let serverCallId = rawCallId.trimmingCharacters(in: .whitespacesAndNewlines) if !serverCallId.isEmpty { extra["call_id"] = serverCallId } } voipDiagnosticsChannel?.invokeMethod( "acceptedVoipCall", arguments: [ "id": callKitId, "handle": conversationUuid, "extra": extra, ] ) } 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() } /// Canonical CallKit id: prefer backend `call_id`, then legacy message-derived /// uuid, then conversation uuid (same resolve order as Dart `VideoCallKitId`). private func resolveCallKitId( serverCallId: String?, conversationUuid: String, messageId: UInt64? ) -> (id: String, source: String) { if let serverCallId, !serverCallId.isEmpty { return (serverCallId, "call_id") } if let derived = derivedCallKitId( conversationUuid: conversationUuid, messageId: messageId ) { return (derived, "conversation_and_message") } if UUID(uuidString: conversationUuid) != nil { return (conversationUuid.lowercased(), "conversation") } return (UUID().uuidString.lowercased(), "generated") } private func voipServerCallId(from dict: [AnyHashable: Any]) -> String? { for key in ["call_id", "callId"] { if let value = dict[key] as? String { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return trimmed } } } return nil } private func voipEndReason(from dict: [AnyHashable: Any]) -> String? { for key in ["reason", "end_reason", "endReason"] { if let value = dict[key] as? String { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return trimmed.lowercased() } } } return nil } private func pruneCallTombstones(now: Date = Date()) { tombstonedCallIds = tombstonedCallIds.filter { now.timeIntervalSince($0.value) < callTombstoneTtl } } private func markCallTombstone(_ callId: String?) { let normalized = callId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" guard !normalized.isEmpty else { return } pruneCallTombstones() tombstonedCallIds[normalized] = Date() DispatchQueue.main.async { [weak self] in self?.voipDiagnosticsChannel?.invokeMethod( "tombstoneCallId", arguments: ["id": normalized] ) } } private func isCallTombstoned(_ callId: String?) -> Bool { let normalized = callId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" guard !normalized.isEmpty else { return false } pruneCallTombstones() return tombstonedCallIds[normalized] != nil } 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 extraMarksEndedPush(_ extra: [String: Any]) -> Bool { let value = extra["endedPush"] if let bool = value as? Bool { return bool } if let number = value as? NSNumber { return number.intValue != 0 } if let text = value as? String { let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return normalized == "true" || normalized == "1" } return false } 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" var queryItems = [ URLQueryItem(name: "callKitId", value: call.data.uuid), URLQueryItem(name: "conversationUuid", value: conversationUuid) ] let serverCallId = extraServerCallId(call.data.extra) if !serverCallId.isEmpty { queryItems.append(URLQueryItem(name: "call_id", value: serverCallId)) } components.queryItems = queryItems 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()) } } /// Ends the ringing CallKit call for an ended VoIP push. /// /// A push that carries `call_id` closes that call. A ring shown from the /// open-app socket used to have only a derived id and an empty `call_id`. /// When nothing matches the server id, those rings still close by /// conversation. A ring that already stores a different `call_id` stays up. private func endCallsForEndedPush( reportedId: String, conversationUuid: String, serverCallId: String? = nil, reason: String? = nil ) { guard let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else { endReportedCallWithoutPlugin(reportedId) releaseIncomingCallClaim(reportedId) return } let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines) let normalizedServerCallId = serverCallId? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let hasServerCallId = !normalizedServerCallId.isEmpty if hasServerCallId { markCallTombstone(normalizedServerCallId) } 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) var matchedServerCall = false for call in plugin.activeCalls() { let callId = (call["id"] as? String) ?? "" let handle = (call["handle"] as? String) ?? "" let extraUuid = extraConversationUuid(call["extra"]) let extraCallId = extraServerCallId(call["extra"]) if hasServerCallId { let matchesServerCall = callId.caseInsensitiveCompare(normalizedServerCallId) == .orderedSame || extraCallId.caseInsensitiveCompare(normalizedServerCallId) == .orderedSame if matchesServerCall { append(callId) matchedServerCall = true } continue } let sameId = callId.caseInsensitiveCompare(reportedId) == .orderedSame let matchesConversation = sameConversation( handle: handle, extraUuid: extraUuid, conversation: conversation ) if sameId || matchesConversation { append(callId) } } if hasServerCallId && !matchedServerCall { for call in plugin.activeCalls() { let callId = (call["id"] as? String) ?? "" let extraCallId = extraServerCallId(call["extra"]) guard extraCallId.isEmpty else { continue } let handle = (call["handle"] as? String) ?? "" let extraUuid = extraConversationUuid(call["extra"]) if sameConversation( handle: handle, extraUuid: extraUuid, conversation: conversation ) { 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 } if hasServerCallId { endExtra["call_id"] = normalizedServerCallId } if let reason, !reason.isEmpty { endExtra["reason"] = reason } endData.extra = endExtra as NSDictionary markRemoteEndedCallKitId(id) plugin.endCall(endData) releaseIncomingCallClaim(id) } } private func activeCallId(matchingServerCallId serverCallId: String) -> String? { let normalized = serverCallId.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalized.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 extraCallId = extraServerCallId(call["extra"]) if callId.caseInsensitiveCompare(normalized) == .orderedSame || extraCallId.caseInsensitiveCompare(normalized) == .orderedSame { return callId } } return nil } private func activeCallId( forConversation conversationUuid: String, onlyWithoutServerCallId: Bool = false ) -> 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 } if onlyWithoutServerCallId && !extraServerCallId(call["extra"]).isEmpty { continue } let handle = (call["handle"] as? String) ?? "" let extraUuid = extraConversationUuid(call["extra"]) if sameConversation(handle: handle, extraUuid: extraUuid, conversation: conversation) { return callId } } return nil } private func sameConversation(handle: String, extraUuid: String, conversation: String) -> Bool { guard !conversation.isEmpty else { return false } return handle.caseInsensitiveCompare(conversation) == .orderedSame || extraUuid.caseInsensitiveCompare(conversation) == .orderedSame } private func markRemoteEndedCallKitId(_ id: String) { let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !normalized.isEmpty else { return } remoteEndedCallLock.lock() remoteEndedCallKitIds.insert(normalized) remoteEndedCallLock.unlock() } private func consumeRemoteEndedCallKitId(_ id: String) -> Bool { let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !normalized.isEmpty else { return false } remoteEndedCallLock.lock() defer { remoteEndedCallLock.unlock() } return remoteEndedCallKitIds.remove(normalized) != nil } private func endReportedCallWithoutPlugin(_ id: String) { let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines) guard let uuid = UUID(uuidString: normalized) else { return } voipCallReporter.provider.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded) } private func extraMap(_ extra: Any?) -> [String: Any] { if let typed = extra as? [String: Any] { return typed } if let object = extra as? NSDictionary { var converted: [String: Any] = [:] for (key, value) in object { if let key = key as? String { converted[key] = value } } return converted } return [:] } private func extraConversationUuid(_ extra: Any?) -> String { let map = extraMap(extra) 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 "" } private func extraServerCallId(_ extra: Any?) -> String { let map = extraMap(extra) for key in ["call_id", "callId"] { 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) }) let serverCallId = voipServerCallId(from: dict) let endReason = voipEndReason(from: dict) if isEndedPush { markCallTombstone(serverCallId) recordVoipDiagnostic( "voip_end_or_cancel_received", data: [ "markers": pushMarkers, "call_id": serverCallId ?? "", "reason": endReason ?? "", "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 tombstonedIncoming = !isEndedPush && isCallTombstoned(serverCallId) let id: String let callKitIdSourceName: String if isEndedPush, let serverCallId, let existing = activeCallId(matchingServerCallId: serverCallId) { id = existing callKitIdSourceName = "active_call_id" } else if isEndedPush, serverCallId?.isEmpty == false, let existing = activeCallId( forConversation: trimmedConversationUuid, onlyWithoutServerCallId: true ) { // The open app reported this ring from the chat socket before `call_id` // was stored. End that ring instead of a new phantom call. id = existing callKitIdSourceName = "active_legacy_ring" } else if isEndedPush, serverCallId == nil, let existing = activeCallId(forConversation: trimmedConversationUuid) { // Legacy ended push without call_id. id = existing callKitIdSourceName = "active_conversation" } else { let resolved = resolveCallKitId( serverCallId: serverCallId, conversationUuid: trimmedConversationUuid, messageId: parsedMessageId ) id = resolved.id callKitIdSourceName = resolved.source } let isVideo = (dict["isVideo"] as? Bool) ?? (dict["is_video"] as? Bool) ?? true var extra: [String: Any] = [:] if !trimmedConversationUuid.isEmpty { extra["conversationUuid"] = trimmedConversationUuid } if let serverCallId, !serverCallId.isEmpty { extra["call_id"] = serverCallId } if let endReason, !endReason.isEmpty { extra["reason"] = endReason } 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 } else if let messageId = parsedMessageId { extra["messageId"] = String(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 || tombstonedIncoming { 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. // Tombstoned late incoming is treated the same way. let matchedActiveRing = callKitIdSourceName == "active_call_id" || callKitIdSourceName == "active_conversation" || callKitIdSourceName == "active_legacy_ring" let isPhantomEndedReport = (isEndedPush && !matchedActiveRing) || tombstonedIncoming 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 || tombstonedIncoming) 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) call_id=\(serverCallId ?? "-") conversation=\(conversationUuid) reason=\(endReason ?? "-") state=\(UIApplication.shared.applicationState.rawValue) markers=\(pushMarkers.joined(separator: ",")) payload=\(dict)" ) recordVoipDiagnostic( "voip_incoming_received", data: [ "callKitId": id, "callKitIdSource": callKitIdSourceName, "call_id": serverCallId ?? "", "reason": endReason ?? "", "tombstoned": tombstonedIncoming, "conversationUuid": conversationUuid, "nameCaller": nameCaller, "isVideo": isVideo, "markers": pushMarkers, "payload": dict, ] ) reportVoipPushToCallKit( data, callKitId: id, callKitIdSourceName: callKitIdSourceName, conversationUuid: conversationUuid, serverCallId: serverCallId, reason: endReason, endAfterReport: isEndedPush || tombstonedIncoming, 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, serverCallId: String? = nil, reason: String? = nil, 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) } let complete = { if endAfterReport { self.endCallsForEndedPush( reportedId: callKitId, conversationUuid: conversationUuid, serverCallId: serverCallId, reason: reason ) } 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, "call_id": serverCallId ?? "", "reason": reason ?? "", "conversationUuid": conversationUuid, "activeCallIds": activeIds, "activeCallsCount": activeCalls.count, "endAfterReport": endAfterReport, ] ) } finish() } if Thread.isMainThread { complete() } else { DispatchQueue.main.async(execute: complete) } } if !endAfterReport { prepareCallAudioCategory() } 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 } if !endAfterReport { prepareCallAudioCategory() } 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) { // CallKit stays up for the whole incoming call. Do not take the session // back after a later deactivate — that path is hang-up. resumeAudioAfterCallKitEnds = false callKitOwnsAudioSession = true // Category only. RTCAudioSession.setConfiguration here races CallKit's // activation and can start I/O before didActivate. prepareAvAudioSessionCategory() 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 callKitOwnsAudioSession = false // POST /decline from this callback. The Flutter event is only local // cleanup and does not run when the user never opens the app. let extra = call.data.extra as? [String: Any] ?? [:] let markedRemoteEnd = consumeRemoteEndedCallKitId(call.data.uuid) let closedByEndedPush = extraMarksEndedPush(extra) || markedRemoteEnd if !closedByEndedPush { VideoCallDeclineClient.send(extra: extra, handle: call.data.handle) recordVoipDiagnostic( "callkit_decline_native", data: [ "callKitId": call.data.uuid, "conversationUuid": conversationUuid(fromCall: call) ?? "", ] ) voipDiagnosticsChannel?.invokeMethod( "declinedVoipCall", arguments: call.data.toJSON() ) } action.fulfill() } func onEnd(_ call: Call, _ action: CXEndCallAction) { // The in-app call is already leaving, or the user hung up from CallKit. // Do not restart the audio unit after this deactivation. resumeAudioAfterCallKitEnds = false audioRestartGeneration += 1 callKitOwnsAudioSession = false action.fulfill() } func onTimeOut(_ call: Call) {} func didActivateAudioSession(_ audioSession: AVAudioSession) { callKitOwnsAudioSession = true let rtcSession = RTCAudioSession.sharedInstance() rtcSession.useManualAudio = true rtcSession.audioSessionDidActivate(audioSession) // WebRTC must start I/O inside this CallKit callback. Enabling later // misses the session CallKit just activated; toggling after that // does not restart the ADM, so the incoming microphone stays silent. rtcSession.isAudioEnabled = true // setCallConnected requests a second CXAnswerCallAction. That fires // didActivate again after the microphone track already exists and can // stop AudioEngine. Dart starts recording on each activation once the // track is published. The first activation (accept, no track yet) is a // no-op on the Dart side. DispatchQueue.main.async { [weak self] in self?.callAudioChannel?.invokeMethod("audioSessionActivated", arguments: nil) } } func didDeactivateAudioSession(_ audioSession: AVAudioSession) { callKitOwnsAudioSession = false 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 callKitOwnsAudioSession = false } private func configureWebRTCAudioSession() { let configuration = callAudioConfiguration() RTCAudioSessionConfiguration.setWebRTC(configuration) } private func prepareAvAudioSessionCategory() { let session = AVAudioSession.sharedInstance() do { try session.setCategory( .playAndRecord, mode: .videoChat, options: [.allowBluetooth, .defaultToSpeaker] ) } catch { NSLog("[EatvioVoIP] prepare call audio category failed: \(error)") } } private func prepareCallAudioCategory() { prepareAvAudioSessionCategory() applyCallAudioConfiguration(active: false) } private func activateInAppCallAudioSession() { let rtcSession = RTCAudioSession.sharedInstance() rtcSession.useManualAudio = true if callKitOwnsAudioSession { rtcSession.isAudioEnabled = true return } applyCallAudioConfiguration(active: true) rtcSession.isAudioEnabled = true } /// Outgoing (no CallKit) may need a teardown to attach capture. Incoming /// already started the unit in didActivate — toggling it here kills ADM. private func restartInAppCallAudioUnit() { let rtcSession = RTCAudioSession.sharedInstance() rtcSession.useManualAudio = true if callKitOwnsAudioSession { rtcSession.isAudioEnabled = true return } audioRestartGeneration += 1 let generation = audioRestartGeneration rtcSession.isAudioEnabled = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in guard let self, generation == self.audioRestartGeneration else { return } self.enableInAppCallAudioUnit() } } private func enableInAppCallAudioUnit() { let rtcSession = RTCAudioSession.sharedInstance() rtcSession.useManualAudio = true if !callKitOwnsAudioSession { applyCallAudioConfiguration(active: true) } rtcSession.isAudioEnabled = true } 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 // Hang-up stops the unit first, then Dart closes CallKit. A second // setActive(false) while CallKit still owns the session fails or // tears the graph down in the wrong order. if callKitOwnsAudioSession { return } 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) {} }