feat(ios): native LiveKit connection in the call plugin (inc 1, WIP)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 16:15:43 +05:30
parent aae663ccea
commit 3e8aaad68c
@@ -3,22 +3,20 @@ import Capacitor
import PushKit import PushKit
import CallKit import CallKit
import AVFoundation import AVFoundation
import LiveKit
// Native calling for Biz Connect (iOS). Registered by `cap sync` as a real Capacitor plugin, so it is // Native calling for Biz Connect (iOS). Registered by `cap sync` as window.Capacitor.Plugins.NativeCall.
// available to the remote web UI as window.Capacitor.Plugins.NativeCall.
// //
// WHAT IT DOES // ARCHITECTURE (native media):
// * PushKit: registers for VoIP pushes and reports the VoIP token to JS -> POST /api/v1/devices ('ios-voip'). // * PushKit: registers for VoIP pushes, reports the VoIP token to JS (-> /api/v1/devices 'ios-voip').
// * CallKit: on an incoming VoIP push it reports a system call (full-screen ring, works when the app is // * CallKit: incoming VoIP push -> full-screen system ring (works when force-killed). The CallKit call
// force-killed). Answer/decline/end come back to JS as events so the web app joins/leaves the LiveKit room. // stays ACTIVE for the whole call (that's what foregrounds/unlocks the app on answer and keeps the call
// * Outgoing: the web app calls reportOutgoingCall() when the user places a call, so THAT call is also a // alive in the background).
// CallKit call which is what grants the app the active-call background-audio context. // * LiveKit: the call MEDIA runs NATIVELY via the LiveKit iOS SDK so the mic works with an active CallKit
// * Audio: CallKit owns the AVAudioSession for the call; we configure it for voice on didActivate so the // call (WebKit's WebRTC could not) and audio survives backgrounding. The VoIP payload carries the
// WebRTC audio (still driven by the WebView) rides on a call-priority session that survives backgrounding. // 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
// CANCELLATION: we deliberately do NOT send "cancel" VoIP pushes (iOS requires a reported call for EVERY // NOT also join the room LiveKit allows one connection per identity).
// 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) @objc(NativeCallPlugin)
public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelegate, CXProviderDelegate { public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelegate, CXProviderDelegate {
public let identifier = "NativeCallPlugin" public let identifier = "NativeCallPlugin"
@@ -26,7 +24,7 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
public let pluginMethods: [CAPPluginMethod] = [ public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "getToken", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getToken", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "reportOutgoingCall", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "reportOutgoingCall", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "callConnected", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "setMuted", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "endCall", returnType: CAPPluginReturnPromise) CAPPluginMethod(name: "endCall", returnType: CAPPluginReturnPromise)
] ]
@@ -34,10 +32,12 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
private var provider: CXProvider? private var provider: CXProvider?
private let callController = CXCallController() private let callController = CXCallController()
private var voipToken: String = "" 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]] = [:] 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). // Calls we've already ended locally so a LATE cancel push doesn't re-report (which briefly re-rang).
private var endedCalls = Set<UUID>() private var endedCalls = Set<UUID>()
private var room: Room?
private var activeUUID: UUID?
override public func load() { override public func load() {
let config = CXProviderConfiguration(localizedName: "Biz Connect") let config = CXProviderConfiguration(localizedName: "Biz Connect")
@@ -55,44 +55,69 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
pushRegistry = registry pushRegistry = registry
} }
// MARK: - JS-callable methods // MARK: - LiveKit media
@objc func getToken(_ call: CAPPluginCall) { private func connectRoom(url: String, token: String) {
call.resolve(["token": voipToken]) 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 private func disconnectRoom() {
// (grants background-audio execution) and the OS call UI is consistent. 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) { @objc func reportOutgoingCall(_ call: CAPPluginCall) {
guard let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) else { guard let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) else {
call.reject("callUUID required"); return 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 handle = CXHandle(type: .generic, value: call.getString("peerName") ?? "Call")
let start = CXStartCallAction(call: uuid, handle: handle) let start = CXStartCallAction(call: uuid, handle: handle)
start.isVideo = call.getBool("hasVideo") ?? false start.isVideo = video
var data: [String: Any] = [:] callController.request(CXTransaction(action: start)) { [weak self] error in
data["room"] = call.getString("room") ?? "" if let error = error { call.reject(error.localizedDescription); return }
data["kind"] = call.getString("kind") ?? "dm" self?.connectRoom(url: url, token: token)
calls[uuid] = data self?.provider?.reportOutgoingCall(with: uuid, connectedAt: Date())
callController.request(CXTransaction(action: start)) { error in call.resolve()
if let error = error { call.reject(error.localizedDescription) } else { call.resolve() }
} }
} }
// Media connected -> start the CallKit timer. @objc func setMuted(_ call: CAPPluginCall) {
@objc func callConnected(_ call: CAPPluginCall) { let muted = call.getBool("muted") ?? false
if let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) { let r = room
provider?.reportOutgoingCall(with: uuid, connectedAt: Date()) Task { try? await r?.localParticipant.setMicrophone(enabled: !muted) }
}
call.resolve() 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) { @objc func endCall(_ call: CAPPluginCall) {
if let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) { if let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) {
requestEnd(uuid) requestEnd(uuid)
} else { } else {
for uuid in calls.keys { requestEnd(uuid) } for uuid in calls.keys { requestEnd(uuid) }
if let a = activeUUID { requestEnd(a) }
} }
call.resolve() call.resolve()
} }
@@ -115,20 +140,17 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
voipToken = "" 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) { 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] = [:] var dict: [String: Any] = [:]
for (k, v) in payload.dictionaryPayload { if let ks = k as? String { dict[ks] = v } } for (k, v) in payload.dictionaryPayload { if let ks = k as? String { dict[ks] = v } }
let uuid = UUID(uuidString: (dict["callUUID"] as? String) ?? "") ?? UUID() 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 // CANCEL: caller hung up / declined / timed out before answer. Still must report (or crash), so
// requires a reported call for every VoIP push, so if we never saw the invite, report then end it. // 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" { 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() let u = CXCallUpdate()
u.remoteHandle = CXHandle(type: .generic, value: (dict["callerName"] as? String) ?? "Call") u.remoteHandle = CXHandle(type: .generic, value: (dict["callerName"] as? String) ?? "Call")
calls.removeValue(forKey: uuid) calls.removeValue(forKey: uuid)
@@ -152,45 +174,36 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
update.supportsGrouping = false update.supportsGrouping = false
update.supportsUngrouping = false update.supportsUngrouping = false
provider?.reportNewIncomingCall(with: uuid, update: update) { _ in provider?.reportNewIncomingCall(with: uuid, update: update) { _ in completion() }
completion()
}
} }
// MARK: - CXProviderDelegate // MARK: - CXProviderDelegate
public func providerDidReset(_ provider: CXProvider) { public func providerDidReset(_ provider: CXProvider) {
calls.removeAll() disconnectRoom()
endedCalls.removeAll() calls.removeAll(); endedCalls.removeAll(); activeUUID = nil
} }
public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
let uuid = action.callUUID let uuid = action.callUUID
var data: [String: Any] = calls[uuid] ?? [:] let data = calls[uuid] ?? [:]
data["callUUID"] = uuid.uuidString activeUUID = uuid
action.fulfill() action.fulfill()
// Keep the CallKit call ACTIVE just long enough fulfilling the answer for an ACTIVE call is what // Keep the CallKit call ACTIVE (foregrounds the app + keeps the call alive). Connect the LiveKit room
// foregrounds/unlocks the app (ending it immediately made iOS cancel the launch the app never // NATIVELY so the mic works with the active CallKit call and audio survives backgrounding.
// opened). Then hand off: fire answerCall so the WebView joins. connectRoom(url: (data["livekitUrl"] as? String) ?? "", token: (data["livekitToken"] as? String) ?? "")
notifyListeners("answerCall", data: data) var ev = data; ev["callUUID"] = uuid.uuidString
// ...and a beat later, once the app is foregrounded, END the CallKit call to RELEASE the mic + audio notifyListeners("answerCall", data: ev)
// 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])
}
} }
public func provider(_ provider: CXProvider, perform action: CXEndCallAction) { public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
var data: [String: Any] = calls[action.callUUID] ?? [:] var data: [String: Any] = calls[action.callUUID] ?? [:]
data["callUUID"] = action.callUUID.uuidString data["callUUID"] = action.callUUID.uuidString
disconnectRoom()
notifyListeners("endCall", data: data) notifyListeners("endCall", data: data)
calls.removeValue(forKey: action.callUUID) calls.removeValue(forKey: action.callUUID)
endedCalls.insert(action.callUUID) endedCalls.insert(action.callUUID)
if activeUUID == action.callUUID { activeUUID = nil }
action.fulfill() action.fulfill()
} }
@@ -200,17 +213,18 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
} }
public func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { 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]) notifyListeners("setMuted", data: ["callUUID": action.callUUID.uuidString, "muted": action.isMuted])
action.fulfill() action.fulfill()
} }
// RING-ONLY MODEL: the call audio runs in the WebView, not through CallKit's session so we do NOT // LiveKit's AudioManager manages the AVAudioSession by default and coordinates with CallKit; we just
// reconfigure the session here (that would fight WebKit's WebRTC). Just report the state. (The CallKit // report the state to JS. (If the mic proves flaky, this is where we'd add manual AudioManager
// call is ended right after answer anyway, so this session is short-lived.) // engine-availability coordination.)
public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) { public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
notifyListeners("audioActivated", data: ["ok": true]) notifyListeners("audioActivated", data: ["ok": true])
} }
public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) { public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
notifyListeners("audioDeactivated", data: ["ok": true]) notifyListeners("audioDeactivated", data: ["ok": true])
} }