From 3e8aaad68c98b12c82c2c4b6f98f013d5361f3ca Mon Sep 17 00:00:00 2001 From: sravan Date: Thu, 30 Jul 2026 16:15:43 +0530 Subject: [PATCH] feat(ios): native LiveKit connection in the call plugin (inc 1, WIP) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin now carries the call media NATIVELY: on answer it connects the LiveKit Room from the VoIP payload's url+token and publishes the mic; outgoing calls connect via reportOutgoingCall(url,token). CallKit stays ACTIVE for the whole call (foregrounds the app, keeps it alive) — the mic works because LiveKit's audio runs natively and coordinates with CallKit (unlike WebKit's WebRTC). setMuted -> setMicrophone; end -> disconnect. Removed the handoff hack. NOT testable yet: still need (a) WebView to stop joining the room for native calls (one connection per identity) and drive outgoing via reportOutgoingCall, and (b) server lifecycle endpoints for native calls (answered/ended), since native media bypasses our mesh/WS signaling. LiveKit Swift API authored without a local compile — expect a build iteration or two. Don't build/flip CALLKIT_ENABLED yet. Co-Authored-By: Claude Opus 4.8 --- .../NativeCallPlugin/NativeCallPlugin.swift | 148 ++++++++++-------- 1 file changed, 81 insertions(+), 67 deletions(-) diff --git a/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift b/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift index 2fac069..67beff7 100644 --- a/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift +++ b/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift @@ -3,22 +3,20 @@ import Capacitor import PushKit import CallKit import AVFoundation +import LiveKit -// 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. +// Native calling for Biz Connect (iOS). Registered by `cap sync` 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. +// 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" @@ -26,7 +24,7 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "getToken", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "reportOutgoingCall", returnType: CAPPluginReturnPromise), - CAPPluginMethod(name: "callConnected", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "setMuted", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "endCall", returnType: CAPPluginReturnPromise) ] @@ -34,10 +32,12 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega 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. + // 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") @@ -55,44 +55,69 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega pushRegistry = registry } - // MARK: - JS-callable methods + // MARK: - LiveKit media - @objc func getToken(_ call: CAPPluginCall) { - call.resolve(["token": voipToken]) + private func connectRoom(url: String, token: String) { + guard !url.isEmpty, !token.isEmpty else { return } + let r = Room() + room = r + Task { [weak self] in + 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)]) + } + } } - // 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. + 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 = 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() } + 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() } } - // 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()) - } + @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 a CallKit call (remote hung up / user ended from the web UI / decline echo). No callUUID -> end all. + // 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() } @@ -115,20 +140,17 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega voipToken = "" } - // Incoming VoIP push. iOS 13+: we MUST report a call to CallKit before completion() or the app is killed. + // 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) { - // 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. + // 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" { - // CRITICAL: iOS TERMINATES the app if a VoIP push does NOT call reportNewIncomingCall (this was the - // crash). So ALWAYS report, then immediately end. If the invite already reported this uuid, the - // report errors (duplicate) and does NOT re-ring; if the app was relaunched by this cancel it - // blips once then ends. The blip is unavoidable — not reporting crashes the app. let u = CXCallUpdate() u.remoteHandle = CXHandle(type: .generic, value: (dict["callerName"] as? String) ?? "Call") calls.removeValue(forKey: uuid) @@ -152,45 +174,36 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega update.supportsGrouping = false update.supportsUngrouping = false - provider?.reportNewIncomingCall(with: uuid, update: update) { _ in - completion() - } + provider?.reportNewIncomingCall(with: uuid, update: update) { _ in completion() } } // MARK: - CXProviderDelegate public func providerDidReset(_ provider: CXProvider) { - calls.removeAll() - endedCalls.removeAll() + disconnectRoom() + calls.removeAll(); endedCalls.removeAll(); activeUUID = nil } public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { let uuid = action.callUUID - var data: [String: Any] = calls[uuid] ?? [:] - data["callUUID"] = uuid.uuidString + let data = calls[uuid] ?? [:] + activeUUID = uuid action.fulfill() - // Keep the CallKit call ACTIVE just long enough — fulfilling the answer for an ACTIVE call is what - // foregrounds/unlocks the app (ending it immediately made iOS cancel the launch → the app never - // opened). Then hand off: fire answerCall so the WebView joins. - notifyListeners("answerCall", data: data) - // ...and a beat later, once the app is foregrounded, END the CallKit call to RELEASE the mic + audio - // session back to the WebView (an active CallKit call reserves the mic → dead mic + earpiece). Ending - // AFTER foreground does NOT cancel the launch. Then tell JS to re-acquire the mic + loudspeaker. - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in - guard let self = self else { return } - self.provider?.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded) - self.calls.removeValue(forKey: uuid) - self.endedCalls.insert(uuid) - self.notifyListeners("callHandoff", data: ["callUUID": uuid.uuidString]) - } + // 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 + notifyListeners("answerCall", data: ev) } 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() } @@ -200,17 +213,18 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega } 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() } - // RING-ONLY MODEL: the call audio runs in the WebView, not through CallKit's session — so we do NOT - // reconfigure the session here (that would fight WebKit's WebRTC). Just report the state. (The CallKit - // call is ended right after answer anyway, so this session is short-lived.) + // 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]) }