Files
eatvio_app/ios/Runner/AppDelegate.swift
2026-10-16 15:41:13 +03:00

1000 lines
34 KiB
Swift

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 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 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) {
let payload = sanitizedDiagnosticValue(call.data.toJSON()) as? [String: Any] ?? [:]
UserDefaults.standard.set(payload, forKey: pendingAcceptedVoipCallKey)
}
private func drainPendingAcceptedVoipCall() -> [String: Any]? {
let defaults = UserDefaults.standard
guard let payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else {
return nil
}
defaults.removeObject(forKey: pendingAcceptedVoipCallKey)
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<String> {
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
)
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
}
// 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
}
let data = flutter_callkit_incoming.Data(
id: id,
nameCaller: nameCaller,
handle: trimmedConversationUuid.isEmpty ? nameCaller : trimmedConversationUuid,
type: isVideo ? 1 : 0
)
data.duration = 45000
data.extra = extra as NSDictionary
// 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,
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.
private func reportVoipPushToCallKit(
_ data: flutter_callkit_incoming.Data,
callKitId: String,
callKitIdSourceName: String,
conversationUuid: String,
endAfterReport: 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 {
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) {}
}