feat(ios): native CallKit + PushKit VoIP calling plugin + web bridge
The native call feature (iOS). Backward-compatible: without the plugin (current builds) nativeCallOn() is false and every CallKit branch is skipped, so web/older builds behave exactly as before. Native (mobile/plugins/native-call, a local Capacitor plugin like audio-route): - PushKit: registers for VoIP pushes, reports the VoIP token to JS (-> /api/v1/devices 'ios-voip'). On an incoming VoIP push, reports a CallKit incoming call (full-screen ring, works when the app is force-killed). - CallKit: answer/decline/end -> events to JS; configures the call AVAudioSession on didActivate so the WebView's WebRTC audio rides a call-priority session (background). - Outgoing calls register with CallKit too (reportOutgoingCall) so they get the same active-call background-audio context. - NativeCall.podspec (frameworks CallKit/PushKit/AVFoundation); added to mobile deps; ios-patch.sh now sets UIBackgroundModes = [audio, voip] (voip required for PushKit). Web bridge (home.html): setupNativeCall() registers the VoIP token, joins on CallKit answer, leaves/declines on CallKit end; on CallKit devices the in-app call-invite popup + WebAudio ring are suppressed (the system rings instead); outgoing calls are reported to CallKit; call-end events dismiss the CallKit call. calls.js threads a stable call uuid through the dm-call/group-call WS events + start responses so both sides can match the CallKit call. Needs a Codemagic build to compile the plugin; first on-device iteration expected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"audio-route": "file:plugins/audio-route",
|
||||
"media-library": "file:plugins/media-library",
|
||||
"native-call": "file:plugins/native-call",
|
||||
"share-inbox": "file:plugins/share-inbox",
|
||||
"@capacitor-community/safe-area": "^7.0.0",
|
||||
"@capacitor/android": "^7.0.0",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
require 'json'
|
||||
|
||||
package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
|
||||
|
||||
Pod::Spec.new do |s|
|
||||
# NOTE: the pod name MUST be 'NativeCall' (PascalCase of the npm package name 'native-call').
|
||||
# Capacitor's `cap sync` writes `pod 'NativeCall', :path => '../../plugins/native-call'` into the
|
||||
# generated Podfile, and CocoaPods then looks for a file literally named NativeCall.podspec whose
|
||||
# s.name is 'NativeCall'. Any other name → "No podspec found for `NativeCall`" and pod install fails.
|
||||
s.name = 'NativeCall'
|
||||
s.version = package['version']
|
||||
s.summary = package['description']
|
||||
s.license = package['license']
|
||||
s.homepage = 'https://bizgaze.com'
|
||||
s.author = 'BizGaze'
|
||||
s.source = { :git => 'https://bizgaze.com/native-call.git', :tag => s.version.to_s }
|
||||
s.source_files = 'ios/Sources/**/*.{swift,h,m}'
|
||||
s.ios.deployment_target = '14.0'
|
||||
s.dependency 'Capacitor'
|
||||
# CallKit + PushKit + AVFoundation are system frameworks (no external pod).
|
||||
s.frameworks = 'CallKit', 'PushKit', 'AVFoundation'
|
||||
s.swift_version = '5.1'
|
||||
end
|
||||
@@ -0,0 +1,178 @@
|
||||
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]] = [:]
|
||||
|
||||
override public func load() {
|
||||
let config = CXProviderConfiguration()
|
||||
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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
let dict = payload.dictionaryPayload
|
||||
let uuid = UUID(uuidString: (dict["callUUID"] as? String) ?? "") ?? UUID()
|
||||
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()
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
||||
var data = calls[action.callUUID] ?? [:]
|
||||
data["callUUID"] = action.callUUID.uuidString
|
||||
notifyListeners("answerCall", data: data)
|
||||
action.fulfill()
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
|
||||
var data = calls[action.callUUID] ?? [:]
|
||||
data["callUUID"] = action.callUUID.uuidString
|
||||
notifyListeners("endCall", data: data)
|
||||
calls.removeValue(forKey: 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()
|
||||
}
|
||||
|
||||
// CallKit hands us the call audio session; configure it for a voice call. The WebView's WebRTC audio
|
||||
// uses this session, and because it's a CallKit call the app keeps running in the background.
|
||||
public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
|
||||
try? audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth, .allowBluetoothA2DP])
|
||||
try? audioSession.setActive(true)
|
||||
notifyListeners("audioActivated", data: [:])
|
||||
}
|
||||
|
||||
public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
|
||||
notifyListeners("audioDeactivated", data: [:])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "native-call",
|
||||
"version": "1.0.0",
|
||||
"description": "Native CallKit + PushKit VoIP calling for Biz Connect (iOS)",
|
||||
"main": "dist/plugin.cjs.js",
|
||||
"module": "dist/esm/index.js",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"author": "BizGaze",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"dist/",
|
||||
"ios/",
|
||||
"NativeCall.podspec"
|
||||
],
|
||||
"capacitor": {
|
||||
"ios": {
|
||||
"src": "ios"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/core": "^7.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": "^7.0.0"
|
||||
}
|
||||
}
|
||||
@@ -46,10 +46,14 @@ set_bool LSSupportsOpeningDocumentsInPlace true
|
||||
# or the phone locks. Declaring background audio keeps the audio session (and the app) running so a voice
|
||||
# call continues in the background. (Video RENDERING still pauses while backgrounded — unavoidable in a
|
||||
# WebView — but audio keeps flowing, which is what matters for a call.) Idempotent: rebuild the array each run.
|
||||
# 'audio' keeps the call audio session alive when backgrounded; 'voip' is REQUIRED for PushKit to deliver
|
||||
# VoIP pushes (CallKit incoming-call wake). Both are legitimate for a calling app and accepted by review
|
||||
# because the app uses CallKit.
|
||||
"$PB" -c "Delete :UIBackgroundModes" "$PLIST" 2>/dev/null || true
|
||||
"$PB" -c "Add :UIBackgroundModes array" "$PLIST"
|
||||
"$PB" -c "Add :UIBackgroundModes:0 string audio" "$PLIST"
|
||||
echo "UIBackgroundModes: audio (calls keep audio when minimised)"
|
||||
"$PB" -c "Add :UIBackgroundModes:1 string voip" "$PLIST"
|
||||
echo "UIBackgroundModes: audio, voip (call audio + CallKit VoIP wake)"
|
||||
|
||||
# ── Custom URL scheme so the Share Extension can bounce the user back into the app ──────────────────
|
||||
# The extension stages the shared files into the App Group, then opens bizconnect://share; the app reads
|
||||
|
||||
Reference in New Issue
Block a user