import Foundation import Capacitor import PushKit import CallKit import AVFoundation // Native calling for Biz Connect (iOS). Registered by `cap sync` as a real Capacitor plugin, so it is // available to the remote web UI as window.Capacitor.Plugins.NativeCall. // // WHAT IT DOES // * PushKit: registers for VoIP pushes and reports the VoIP token to JS -> POST /api/v1/devices ('ios-voip'). // * CallKit: on an incoming VoIP push it reports a system call (full-screen ring, works when the app is // force-killed). Answer/decline/end come back to JS as events so the web app joins/leaves the LiveKit room. // * Outgoing: the web app calls reportOutgoingCall() when the user places a call, so THAT call is also a // CallKit call — which is what grants the app the active-call background-audio context. // * Audio: CallKit owns the AVAudioSession for the call; we configure it for voice on didActivate so the // WebRTC audio (still driven by the WebView) rides on a call-priority session that survives backgrounding. // // CANCELLATION: we deliberately do NOT send "cancel" VoIP pushes (iOS requires a reported call for EVERY // VoIP push). Instead the app is already awake after the invite push, so a caller hang-up arrives over the // normal chat WebSocket and the web app calls endCall() to dismiss the CallKit ring. @objc(NativeCallPlugin) public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelegate, CXProviderDelegate { public let identifier = "NativeCallPlugin" public let jsName = "NativeCall" public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "getToken", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "reportOutgoingCall", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "callConnected", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "endCall", returnType: CAPPluginReturnPromise) ] private var pushRegistry: PKPushRegistry? private var provider: CXProvider? private let callController = CXCallController() private var voipToken: String = "" // callUUID -> the call's data (room, kind, callerId, …) so answer/end can hand it back to JS. private var calls: [UUID: [String: Any]] = [:] // Calls we've already ended locally — so a LATE cancel push doesn't re-report (which briefly re-rang). private var endedCalls = Set() override public func load() { let config = CXProviderConfiguration(localizedName: "Biz Connect") config.supportsVideo = true config.maximumCallGroups = 1 config.maximumCallsPerCallGroup = 1 config.supportedHandleTypes = [.generic] let p = CXProvider(configuration: config) p.setDelegate(self, queue: nil) provider = p let registry = PKPushRegistry(queue: .main) registry.delegate = self registry.desiredPushTypes = [.voIP] pushRegistry = registry } // MARK: - JS-callable methods @objc func getToken(_ call: CAPPluginCall) { call.resolve(["token": voipToken]) } // The web app places an outgoing call -> register it with CallKit so the system knows a call is active // (grants background-audio execution) and the OS call UI is consistent. @objc func reportOutgoingCall(_ call: CAPPluginCall) { guard let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) else { call.reject("callUUID required"); return } let handle = CXHandle(type: .generic, value: call.getString("peerName") ?? "Call") let start = CXStartCallAction(call: uuid, handle: handle) start.isVideo = call.getBool("hasVideo") ?? false var data: [String: Any] = [:] data["room"] = call.getString("room") ?? "" data["kind"] = call.getString("kind") ?? "dm" calls[uuid] = data callController.request(CXTransaction(action: start)) { error in if let error = error { call.reject(error.localizedDescription) } else { call.resolve() } } } // Media connected -> start the CallKit timer. @objc func callConnected(_ call: CAPPluginCall) { if let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) { provider?.reportOutgoingCall(with: uuid, connectedAt: Date()) } call.resolve() } // End a CallKit call (remote hung up / user ended from the web UI / decline echo). No callUUID -> end all. @objc func endCall(_ call: CAPPluginCall) { if let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) { requestEnd(uuid) } else { for uuid in calls.keys { requestEnd(uuid) } } call.resolve() } private func requestEnd(_ uuid: UUID) { callController.request(CXTransaction(action: CXEndCallAction(call: uuid))) { _ in } calls.removeValue(forKey: uuid) endedCalls.insert(uuid) } // MARK: - PushKit (VoIP) public func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { let token = pushCredentials.token.map { String(format: "%02x", $0) }.joined() voipToken = token notifyListeners("voipToken", data: ["token": token]) } public func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) { voipToken = "" } // Incoming VoIP push. iOS 13+: we MUST report a call to CallKit before completion() or the app is killed. public func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { // payload.dictionaryPayload is [AnyHashable: Any]; normalise to [String: Any] for CallKit + notifyListeners. var dict: [String: Any] = [:] for (k, v) in payload.dictionaryPayload { if let ks = k as? String { dict[ks] = v } } let uuid = UUID(uuidString: (dict["callUUID"] as? String) ?? "") ?? UUID() // CANCEL: the caller hung up / declined / it timed out before we answered → stop ringing. iOS still // requires a reported call for every VoIP push, so if we never saw the invite, report then end it. if (dict["type"] as? String) == "cancel" { if calls[uuid] != nil { provider?.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded) calls.removeValue(forKey: uuid) endedCalls.insert(uuid) completion() } else if endedCalls.contains(uuid) { // Already ended locally (we declined/hung up) — do NOT re-report; that caused the ~1s re-ring. completion() } else { // Never saw the invite — iOS still requires a reported call for this push, so report then end. let u = CXCallUpdate() u.remoteHandle = CXHandle(type: .generic, value: "Call") endedCalls.insert(uuid) provider?.reportNewIncomingCall(with: uuid, update: u) { [weak self] _ in self?.provider?.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded) completion() } } return } let callerName = (dict["callerName"] as? String) ?? (dict["groupName"] as? String) ?? "Incoming call" let hasVideo = (dict["hasVideo"] as? Bool) ?? false calls[uuid] = dict let update = CXCallUpdate() update.remoteHandle = CXHandle(type: .generic, value: callerName) update.localizedCallerName = callerName update.hasVideo = hasVideo update.supportsHolding = false update.supportsGrouping = false update.supportsUngrouping = false provider?.reportNewIncomingCall(with: uuid, update: update) { _ in completion() } } // MARK: - CXProviderDelegate public func providerDidReset(_ provider: CXProvider) { calls.removeAll() endedCalls.removeAll() } public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { var data: [String: Any] = calls[action.callUUID] ?? [:] data["callUUID"] = action.callUUID.uuidString notifyListeners("answerCall", data: data) action.fulfill() } public func provider(_ provider: CXProvider, perform action: CXEndCallAction) { var data: [String: Any] = calls[action.callUUID] ?? [:] data["callUUID"] = action.callUUID.uuidString notifyListeners("endCall", data: data) calls.removeValue(forKey: action.callUUID) endedCalls.insert(action.callUUID) action.fulfill() } public func provider(_ provider: CXProvider, perform action: CXStartCallAction) { provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: Date()) action.fulfill() } public func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { notifyListeners("setMuted", data: ["callUUID": action.callUUID.uuidString, "muted": action.isMuted]) action.fulfill() } // CallKit hands us the call audio session; configure it for a voice call. The WebView's WebRTC audio // uses this session, and because it's a CallKit call the app keeps running in the background. public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { try? audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP]) try? audioSession.setActive(true) notifyListeners("audioActivated", data: ["ok": true]) } public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { notifyListeners("audioDeactivated", data: ["ok": true]) } }