import Foundation import Capacitor import PushKit import CallKit import AVFoundation import LiveKitClient // the CocoaPod is 'LiveKitClient' (no module_name), so the Swift module is LiveKitClient — NOT LiveKit // Native calling for Biz Connect (iOS). Registered by `cap sync` as window.Capacitor.Plugins.NativeCall. // // ARCHITECTURE (native media): // * PushKit: registers for VoIP pushes, reports the VoIP token to JS (-> /api/v1/devices 'ios-voip'). // * CallKit: incoming VoIP push -> full-screen system ring (works when force-killed). The CallKit call // stays ACTIVE for the whole call (that's what foregrounds/unlocks the app on answer and keeps the call // alive in the background). // * LiveKit: the call MEDIA runs NATIVELY via the LiveKit iOS SDK — so the mic works with an active CallKit // call (WebKit's WebRTC could not) and audio survives backgrounding. The VoIP payload carries the // LiveKit url+token so we can connect immediately on answer, even from a killed state; outgoing calls get // the token from the WebView via reportOutgoingCall(). The WebView is UI only for native calls (it must // NOT also join the room — LiveKit allows one connection per identity). @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: "setMuted", 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, livekitUrl, livekitToken, …). 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() private var room: Room? private var activeUUID: UUID? 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: - LiveKit media private func connectRoom(url: String, token: String) { guard !url.isEmpty, !token.isEmpty else { return } let old = room let r = Room() room = r Task { [weak self] in await old?.disconnect() // drop any previous/stale connection so we never leave DUPLICATE participants do { try await r.connect(url: url, token: token) try await r.localParticipant.setMicrophone(enabled: true) self?.notifyListeners("callConnected", data: ["ok": true]) } catch { self?.notifyListeners("callError", data: ["error": String(describing: error)]) } } } private func disconnectRoom() { let r = room room = nil Task { await r?.disconnect() } } // MARK: - JS-callable methods @objc func getToken(_ call: CAPPluginCall) { call.resolve(["token": voipToken]) } // Outgoing call from the web app: start a CallKit outgoing call AND connect the LiveKit room natively. @objc func reportOutgoingCall(_ call: CAPPluginCall) { guard let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) else { call.reject("callUUID required"); return } let url = call.getString("url") ?? "" let token = call.getString("token") ?? "" let video = call.getBool("hasVideo") ?? false calls[uuid] = ["room": call.getString("room") ?? "", "kind": call.getString("kind") ?? "dm", "livekitUrl": url, "livekitToken": token] activeUUID = uuid let handle = CXHandle(type: .generic, value: call.getString("peerName") ?? "Call") let start = CXStartCallAction(call: uuid, handle: handle) start.isVideo = video callController.request(CXTransaction(action: start)) { [weak self] error in if let error = error { call.reject(error.localizedDescription); return } self?.connectRoom(url: url, token: token) self?.provider?.reportOutgoingCall(with: uuid, connectedAt: Date()) call.resolve() } } @objc func setMuted(_ call: CAPPluginCall) { let muted = call.getBool("muted") ?? false let r = room Task { try? await r?.localParticipant.setMicrophone(enabled: !muted) } call.resolve() } // End the CallKit call (remote hung up / user ended from the web UI). 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) } if let a = activeUUID { requestEnd(a) } } 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 reportNewIncomingCall for EVERY push before completion(), or the // app is terminated. public func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { 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: caller hung up / declined / timed out before answer. Still must report (or crash), so // report then immediately end. If already reported the report errors (no re-ring); if relaunched it // blips once then ends. if (dict["type"] as? String) == "cancel" { let u = CXCallUpdate() u.remoteHandle = CXHandle(type: .generic, value: (dict["callerName"] as? String) ?? "Call") calls.removeValue(forKey: uuid) 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) { disconnectRoom() calls.removeAll(); endedCalls.removeAll(); activeUUID = nil } public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { let uuid = action.callUUID let data = calls[uuid] ?? [:] activeUUID = uuid action.fulfill() // Keep the CallKit call ACTIVE (foregrounds the app + keeps the call alive). Connect the LiveKit room // NATIVELY so the mic works with the active CallKit call and audio survives backgrounding. connectRoom(url: (data["livekitUrl"] as? String) ?? "", token: (data["livekitToken"] as? String) ?? "") var ev = data; ev["callUUID"] = uuid.uuidString // Answering from a KILLED/locked state launches the app — the WebView's JS listener may not be attached // yet, so retain the event until it is. Without this the "answered" signal is lost, the server's // unanswered timer fires, and the call is cancelled ~40s after pickup. notifyListeners("answerCall", data: ev, retainUntilConsumed: true) } public func provider(_ provider: CXProvider, perform action: CXEndCallAction) { var data: [String: Any] = calls[action.callUUID] ?? [:] data["callUUID"] = action.callUUID.uuidString disconnectRoom() notifyListeners("endCall", data: data) calls.removeValue(forKey: action.callUUID) endedCalls.insert(action.callUUID) if activeUUID == action.callUUID { activeUUID = nil } 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) { let r = room Task { try? await r?.localParticipant.setMicrophone(enabled: !action.isMuted) } notifyListeners("setMuted", data: ["callUUID": action.callUUID.uuidString, "muted": action.isMuted]) action.fulfill() } // LiveKit's AudioManager manages the AVAudioSession by default and coordinates with CallKit; we just // report the state to JS. (If the mic proves flaky, this is where we'd add manual AudioManager // engine-availability coordination.) public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { notifyListeners("audioActivated", data: ["ok": true]) } public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { notifyListeners("audioDeactivated", data: ["ok": true]) } }