forked from eatvio/eatvio_app
518 lines
16 KiB
Swift
518 lines
16 KiB
Swift
import AVFAudio
|
|
import CallKit
|
|
import Flutter
|
|
import PushKit
|
|
import UIKit
|
|
import UserNotifications
|
|
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 appUrlScheme = "eatvio"
|
|
|
|
override func application(
|
|
_ application: UIApplication,
|
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
|
) -> Bool {
|
|
GeneratedPluginRegistrant.register(with: self)
|
|
SwiftFlutterForegroundTaskPlugin.setPluginRegistrantCallback { registry in
|
|
GeneratedPluginRegistrant.register(with: registry)
|
|
}
|
|
|
|
let mainQueue = DispatchQueue.main
|
|
voipRegistry = PKPushRegistry(queue: mainQueue)
|
|
voipRegistry?.delegate = self
|
|
voipRegistry?.desiredPushTypes = [PKPushType.voIP]
|
|
|
|
if #available(iOS 10.0, *) {
|
|
UNUserNotificationCenter.current().delegate = self
|
|
}
|
|
|
|
let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
|
|
|
if let controller = window?.rootViewController as? FlutterViewController {
|
|
let channel = FlutterMethodChannel(
|
|
name: voipDiagnosticsChannelName,
|
|
binaryMessenger: controller.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())
|
|
default:
|
|
result(FlutterMethodNotImplemented)
|
|
}
|
|
}
|
|
}
|
|
|
|
return launched
|
|
}
|
|
|
|
@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 {
|
|
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: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String
|
|
)
|
|
}
|
|
|
|
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)
|
|
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
|
|
}
|
|
|
|
|
|
func pushRegistry(
|
|
_ registry: PKPushRegistry,
|
|
didUpdate credentials: PKPushCredentials,
|
|
for type: PKPushType
|
|
) {
|
|
let deviceToken = credentials.token.map { String(format: "%02x", $0) }.joined()
|
|
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(deviceToken)
|
|
}
|
|
|
|
func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
|
|
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: "_") }
|
|
|
|
if pushMarkers.contains(where: { $0.contains("video_call") })
|
|
&& pushMarkers.contains(where: { $0.contains("end") || $0.contains("cancel") }) {
|
|
recordVoipDiagnostic(
|
|
"voip_end_or_cancel_received",
|
|
data: [
|
|
"markers": pushMarkers,
|
|
"payload": dict,
|
|
]
|
|
)
|
|
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.endAllCalls()
|
|
completion()
|
|
return
|
|
}
|
|
|
|
let nameCaller = (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 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 callKitIdSource = [
|
|
(key: "call_id", value: dict["call_id"] as? String),
|
|
(key: "id", value: dict["id"] as? String),
|
|
(key: "message_id", value: dict["message_id"] as? String),
|
|
]
|
|
.first { item in
|
|
guard let trimmed = item.value?.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
|
return false
|
|
}
|
|
return !trimmed.isEmpty && UUID(uuidString: trimmed) != nil
|
|
}
|
|
let id = callKitIdSource?.value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? UUID().uuidString
|
|
let callKitIdSourceName = callKitIdSource?.key ?? "generated"
|
|
let isVideo = (dict["isVideo"] as? Bool)
|
|
?? (dict["is_video"] as? Bool)
|
|
?? true
|
|
|
|
var extra: [String: Any] = [:]
|
|
if !conversationUuid.isEmpty {
|
|
extra["conversationUuid"] = conversationUuid
|
|
}
|
|
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
|
|
}
|
|
|
|
let data = flutter_callkit_incoming.Data(
|
|
id: id,
|
|
nameCaller: nameCaller,
|
|
handle: conversationUuid.isEmpty ? nameCaller : conversationUuid,
|
|
type: isVideo ? 1 : 0
|
|
)
|
|
data.extra = extra as NSDictionary
|
|
|
|
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,
|
|
]
|
|
)
|
|
|
|
guard let callkitPlugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else {
|
|
NSLog("[EatvioVoIP] CallKit plugin missing id=\(id)")
|
|
recordVoipDiagnostic(
|
|
"callkit_plugin_missing",
|
|
data: [
|
|
"callKitId": id,
|
|
"conversationUuid": conversationUuid,
|
|
]
|
|
)
|
|
completion()
|
|
return
|
|
}
|
|
|
|
callkitPlugin.showCallkitIncoming(
|
|
data,
|
|
fromPushKit: true
|
|
) {
|
|
let activeCalls = callkitPlugin.activeCalls()
|
|
let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
|
|
NSLog(
|
|
"[EatvioVoIP] report incoming completed id=\(id) activeCallIds=\(activeIds)"
|
|
)
|
|
self.recordVoipDiagnostic(
|
|
"callkit_report_completed",
|
|
data: [
|
|
"callKitId": id,
|
|
"callKitIdSource": callKitIdSourceName,
|
|
"conversationUuid": conversationUuid,
|
|
"activeCallIds": activeIds,
|
|
"activeCallsCount": activeCalls.count,
|
|
]
|
|
)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
|
let delayedActiveCalls = callkitPlugin.activeCalls()
|
|
let delayedActiveIds = delayedActiveCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
|
|
self.recordVoipDiagnostic(
|
|
"callkit_active_calls_delayed",
|
|
data: [
|
|
"callKitId": id,
|
|
"callKitIdSource": callKitIdSourceName,
|
|
"conversationUuid": conversationUuid,
|
|
"activeCallIds": delayedActiveIds,
|
|
"activeCallsCount": delayedActiveCalls.count,
|
|
]
|
|
)
|
|
}
|
|
completion()
|
|
}
|
|
}
|
|
|
|
|
|
func onAccept(_ call: Call, _ action: CXAnswerCallAction) {
|
|
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) {
|
|
action.fulfill()
|
|
}
|
|
|
|
func onEnd(_ call: Call, _ action: CXEndCallAction) {
|
|
action.fulfill()
|
|
}
|
|
|
|
func onTimeOut(_ call: Call) {}
|
|
|
|
func didActivateAudioSession(_ audioSession: AVAudioSession) {}
|
|
|
|
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {}
|
|
|
|
func providerDidReset() {}
|
|
}
|