import UIKit import Flutter import Firebase import PushKit import CallKit import AVFAudio import flutter_callkit_incoming @main @objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate { // Expose the Flutter engine for SceneDelegate to use lazy var flutterEngine = FlutterEngine(name: "my flutter engine") // Must be retained for the lifetime of the app, otherwise VoIP pushes stop. private var voipRegistry: PKPushRegistry? // Keeps the app alive briefly after a decline so Dart can notify the caller. private var declineTask: UIBackgroundTaskIdentifier = .invalid override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { // Initialize Firebase FirebaseApp.configure() // Start running the Flutter engine flutterEngine.run() // Register plugins with the engine GeneratedPluginRegistrant.register(with: self.flutterEngine) // Register for VoIP pushes AFTER plugins, so flutter_callkit_incoming is ready. let registry = PKPushRegistry(queue: .main) registry.delegate = self registry.desiredPushTypes = [.voIP] voipRegistry = registry return super.application(application, didFinishLaunchingWithOptions: launchOptions) } // UIScene lifecycle support override func application( _ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions ) -> UISceneConfiguration { let sceneConfiguration = UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) sceneConfiguration.delegateClass = SceneDelegate.self return sceneConfiguration } // MARK: - CallkitIncomingAppDelegate // The plugin hands these actions to the app delegate; every one must be fulfilled // (that is what the plugin does itself when the delegate does not conform). func onAccept(_ call: Call, _ action: CXAnswerCallAction) { action.fulfill() } func onDecline(_ call: Call, _ action: CXEndCallAction) { action.fulfill() // App launched in the background by a VoIP push (killed state): once the call is declined // iOS may suspend the app almost immediately. Keep it alive briefly so the Dart decline // handler (CallKitEvents in firebase_notification_manger.dart) can notify the caller. beginDeclineBackgroundTask() } func onEnd(_ call: Call, _ action: CXEndCallAction) { action.fulfill() } func onTimeOut(_ call: Call) {} func didActivateAudioSession(_ audioSession: AVAudioSession) {} func didDeactivateAudioSession(_ audioSession: AVAudioSession) {} func providerDidReset() {} private func beginDeclineBackgroundTask() { endDeclineBackgroundTask() declineTask = UIApplication.shared.beginBackgroundTask(withName: "CallDecline") { [weak self] in self?.endDeclineBackgroundTask() } // Enough for the token request + SignalR connect + CallDeclinedAsync. DispatchQueue.main.asyncAfter(deadline: .now() + 20) { [weak self] in self?.endDeclineBackgroundTask() } } private func endDeclineBackgroundTask() { guard declineTask != .invalid else { return } UIApplication.shared.endBackgroundTask(declineTask) declineTask = .invalid } // MARK: - PushKit (VoIP) func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { guard type == .voIP else { return } let token = pushCredentials.token.map { String(format: "%02x", $0) }.joined() // Persisted by the plugin; read in Dart via FlutterCallkitIncoming.getDevicePushTokenVoIP(). SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(token) } func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) { guard type == .voIP else { return } SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("") } // iOS 13+: every VoIP push MUST be reported to CallKit before `completion` is called, // or iOS terminates the app and eventually stops delivering VoIP pushes to it. func pushRegistry( _ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void ) { guard type == .voIP, let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else { completion() return } let data = flutter_callkit_incoming.Data(args: AppDelegate.callKitArgs(from: payload.dictionaryPayload)) plugin.showCallkitIncoming(data, fromPushKit: true) { completion() } } /// Builds the same structure Dart's `CallKitParams.toJson()` produces in /// `handleDataMessage` (firebase_notification_manger.dart), so both paths behave identically. /// Accepts the call fields at the payload root, or nested under "data"/"extra"/"payload" /// (as a dictionary or a JSON string). private static func callKitArgs(from raw: [AnyHashable: Any]) -> [String: Any?] { var p: [String: Any] = [:] for (k, v) in raw { if let key = k as? String { p[key] = v } } for key in ["data", "extra", "payload"] { if let nested = p[key] as? [String: Any] { p.merge(nested) { _, new in new } } else if let json = p[key] as? String, let d = json.data(using: .utf8), let nested = (try? JSONSerialization.jsonObject(with: d)) as? [String: Any] { p.merge(nested) { _, new in new } } } func str(_ keys: String...) -> String? { for k in keys { if let v = p[k], !(v is NSNull) { let s = "\(v)" if !s.isEmpty { return s } } } return nil } let callId = str("callId", "sessionId") ?? UUID().uuidString // CallKit requires a real UUID; the backend id is kept in `extra.callId`. let uuid = UUID(uuidString: callId)?.uuidString ?? UUID().uuidString let callerId = str("callerId", "callerEmployeeNumber", "sourceUserId") ?? "unknown" let callerName = str("callerName", "callerUserName", "userName") ?? "Unknown Caller" let callerEmployeeNumber = str("callerEmployeeNumber") ?? callerId let calleeEmployeeNumber = str("calleeEmployeeNumber") ?? callerId let isVideoCall: Bool = { if let b = p["isVideoCall"] as? Bool { return b } if let s = p["isVideoCall"] as? String { return s.lowercased() == "true" } return str("callType")?.lowercased() == "video" }() return [ "id": uuid, "nameCaller": callerName, "appName": "ATOMS", "avatar": "", "handle": callerEmployeeNumber, "type": isVideoCall ? 1 : 0, "duration": 30000, "extra": [ "callId": callId, "callerId": callerId, "callerEmployeeNumber": callerEmployeeNumber, "calleeEmployeeNumber": calleeEmployeeNumber, "callerName": callerName, "moduleId": str("moduleId", "applicationId") ?? "", "referenceId": str("referenceId") ?? "", "conversationId": str("conversationId") ?? "", "isVideoCall": isVideoCall, "timestamp": ISO8601DateFormatter().string(from: Date()), ] as [String: Any], "missedCallNotification": [ "showNotification": true, "isShowCallback": false, "subtitle": "Missed call", "callbackText": "Call back", ] as [String: Any], "callingNotification": [ "showNotification": true, "isShowCallback": true, "subtitle": "Calling...", "callbackText": "Hang Up", ] as [String: Any], "ios": [ "iconName": "CallKitLogo", "handleType": "generic", "supportsVideo": true, "maximumCallGroups": 1, "maximumCallsPerCallGroup": 1, // Don't let the plugin activate a playAndRecord audio session while ringing: while the app // holds an active session, iOS routes the volume/side buttons to it instead of the CallKit // call (no silence on press, no decline on double-press). CallKit activates audio on // answer and flutter_webrtc configures it for the call. "configureAudioSession": false, "audioSessionMode": "default", "audioSessionActive": true, "audioSessionPreferredSampleRate": 44100.0, "audioSessionPreferredIOBufferDuration": 0.005, "supportsDTMF": true, "supportsHolding": false, "supportsGrouping": false, "supportsUngrouping": false, "ringtonePath": "system_ringtone_default", ] as [String: Any], ] } }