6096b62368
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>
220 lines
10 KiB
Swift
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])
|
|
}
|
|
}
|