forked from eatvio/eatvio_app
1000 lines
34 KiB
Swift
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) {}
|
|
}
|