Files
BizGaze_Remote/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift
T

232 lines
10 KiB
Swift
Raw Normal View History

import Foundation
import Capacitor
import PushKit
import CallKit
import AVFoundation
import LiveKit
// Native calling for Biz Connect (iOS). Registered by `cap sync` as window.Capacitor.Plugins.NativeCall.
//
// 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"
public let jsName = "NativeCall"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "getToken", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "reportOutgoingCall", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setMuted", 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, 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<UUID>()
private var room: Room?
private var activeUUID: 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: - LiveKit media
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)])
}
}
}
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 = 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()
}
}
@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 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()
}
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 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) {
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: 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" {
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)
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) {
disconnectRoom()
calls.removeAll(); endedCalls.removeAll(); activeUUID = nil
}
public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
let uuid = action.callUUID
let data = calls[uuid] ?? [:]
activeUUID = uuid
action.fulfill()
// 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()
}
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) {
let r = room
Task { try? await r?.localParticipant.setMicrophone(enabled: !action.isMuted) }
notifyListeners("setMuted", data: ["callUUID": action.callUUID.uuidString, "muted": action.isMuted])
action.fulfill()
}
// 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])
}
}