Files
BizGaze_Remote/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift
T
Sravan 6096b62368 feat(calls): CallKit ring-only + WebView media (background audio via voip mode)
Pivot away from the native-LiveKit rewrite. Discovery: the 'voip' UIBackgroundMode
already keeps the WebView's call audio alive when backgrounded (confirmed on device),
so background audio is solved WITHOUT native media. The only issue was CallKit
reserving the mic. So use CallKit purely for the incoming RING:

- Plugin CXAnswerCallAction: fulfill, then immediately end the CallKit call
  (reportCall endedAt) to RELEASE the mic, and fire answerCall to the WebView after a
  ~1s beat so iOS tears down the CallKit audio session first. didActivate no longer
  reconfigures the session (was fighting WebKit).
- home.html: outgoing calls no longer register with CallKit (WebView-only → mic works);
  incoming still rings via CallKit → hands off to the WebView on answer.

Net: native full-screen ring + working mic + background audio + all existing call
features. Needs a Codemagic build; then flip CALLKIT_ENABLED=1 to test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 18:25:59 +05:30

220 lines
10 KiB
Swift

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<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: - 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) {
let uuid = action.callUUID
var data: [String: Any] = calls[uuid] ?? [:]
data["callUUID"] = uuid.uuidString
action.fulfill()
// RING-ONLY MODEL: CallKit was used purely for the incoming ring. End the CallKit call NOW so iOS
// RELEASES the microphone — an active CallKit call reserves the mic and the WebView's WebRTC would get
// a dead mic. Then hand the call to the WebView, giving iOS a beat to tear down the CallKit audio
// session before the WebView grabs the mic. Background audio is preserved by the 'voip' background mode.
provider.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded)
calls.removeValue(forKey: uuid)
endedCalls.insert(uuid)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
self?.notifyListeners("answerCall", data: data)
}
}
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()
}
// 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.)
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])
}
}