2026-07-28 22:34:27 +05:30
|
|
|
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]] = [:]
|
2026-07-29 17:54:21 +05:30
|
|
|
// Calls we've already ended locally — so a LATE cancel push doesn't re-report (which briefly re-rang).
|
|
|
|
|
private var endedCalls = Set<UUID>()
|
2026-07-28 22:34:27 +05:30
|
|
|
|
|
|
|
|
override public func load() {
|
2026-07-28 23:03:01 +05:30
|
|
|
let config = CXProviderConfiguration(localizedName: "Biz Connect")
|
2026-07-28 22:34:27 +05:30
|
|
|
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)
|
2026-07-29 17:54:21 +05:30
|
|
|
endedCalls.insert(uuid)
|
2026-07-28 22:34:27 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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) {
|
2026-07-28 23:03:01 +05:30
|
|
|
// 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 } }
|
2026-07-28 22:34:27 +05:30
|
|
|
let uuid = UUID(uuidString: (dict["callUUID"] as? String) ?? "") ?? UUID()
|
2026-07-29 11:09:07 +05:30
|
|
|
|
|
|
|
|
// 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" {
|
2026-07-29 22:48:24 +05:30
|
|
|
// 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)
|
|
|
|
|
endedCalls.insert(uuid)
|
|
|
|
|
provider?.reportNewIncomingCall(with: uuid, update: u) { [weak self] _ in
|
|
|
|
|
self?.provider?.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded)
|
2026-07-29 17:54:21 +05:30
|
|
|
completion()
|
2026-07-29 11:09:07 +05:30
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 22:34:27 +05:30
|
|
|
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()
|
2026-07-29 17:54:21 +05:30
|
|
|
endedCalls.removeAll()
|
2026-07-28 22:34:27 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
2026-07-29 18:25:59 +05:30
|
|
|
let uuid = action.callUUID
|
|
|
|
|
var data: [String: Any] = calls[uuid] ?? [:]
|
|
|
|
|
data["callUUID"] = uuid.uuidString
|
2026-07-28 22:34:27 +05:30
|
|
|
action.fulfill()
|
2026-07-29 22:48:24 +05:30
|
|
|
// 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.
|
2026-07-29 20:44:09 +05:30
|
|
|
notifyListeners("answerCall", data: data)
|
2026-07-29 22:48:24 +05:30
|
|
|
// ...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])
|
|
|
|
|
}
|
2026-07-28 22:34:27 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
|
2026-07-28 23:03:01 +05:30
|
|
|
var data: [String: Any] = calls[action.callUUID] ?? [:]
|
2026-07-28 22:34:27 +05:30
|
|
|
data["callUUID"] = action.callUUID.uuidString
|
|
|
|
|
notifyListeners("endCall", data: data)
|
|
|
|
|
calls.removeValue(forKey: action.callUUID)
|
2026-07-29 17:54:21 +05:30
|
|
|
endedCalls.insert(action.callUUID)
|
2026-07-28 22:34:27 +05:30
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 18:25:59 +05:30
|
|
|
// 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.)
|
2026-07-28 22:34:27 +05:30
|
|
|
public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
|
2026-07-28 23:03:01 +05:30
|
|
|
notifyListeners("audioActivated", data: ["ok": true])
|
2026-07-28 22:34:27 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
|
2026-07-28 23:03:01 +05:30
|
|
|
notifyListeners("audioDeactivated", data: ["ok": true])
|
2026-07-28 22:34:27 +05:30
|
|
|
}
|
|
|
|
|
}
|