feat(calls): CallKit kill-switch + fix ~1s re-ring on cancel

MIC BROKEN with CallKit: a CallKit call reserves the microphone, so the WebView's
WebRTC can't capture it — calls are unusable until native LiveKit media lands. Add a
server kill-switch (CALLKIT_ENABLED, default OFF) so CallKit can be flipped without an
app rebuild: /api/meetings/config now returns callkit; setupNativeCall bails when off
(-> WebView calls, mic works); push.js only sends VoIP/CallKit pushes when enabled.
Deploying with the flag unset immediately restores working WebView calls.

RE-RING: a late cancel push for an already-declined call hit the plugin's 'unknown
uuid' path and re-reported a fresh incoming call (~1s re-ring). Track endedCalls and
make a late cancel for an already-ended call a no-op.

Server part deploys now (no rebuild); plugin re-ring fix ships with the native build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 17:54:21 +05:30
parent 3e21aa30d7
commit e7c3231c50
5 changed files with 26 additions and 4 deletions
@@ -36,6 +36,8 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
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")
@@ -98,6 +100,7 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
private func requestEnd(_ uuid: UUID) {
callController.request(CXTransaction(action: CXEndCallAction(call: uuid))) { _ in }
calls.removeValue(forKey: uuid)
endedCalls.insert(uuid)
}
// MARK: - PushKit (VoIP)
@@ -125,10 +128,16 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
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()
@@ -158,6 +167,7 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
public func providerDidReset(_ provider: CXProvider) {
calls.removeAll()
endedCalls.removeAll()
}
public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
@@ -172,6 +182,7 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
data["callUUID"] = action.callUUID.uuidString
notifyListeners("endCall", data: data)
calls.removeValue(forKey: action.callUUID)
endedCalls.insert(action.callUUID)
action.fulfill()
}
+7
View File
@@ -42,6 +42,12 @@ const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || 'https://remote.bizgaze.
// calls our /api/gifs proxy. GIF picker is hidden when this isn't configured.
const GIPHY_API_KEY = process.env.GIPHY_API_KEY || '';
// Native CallKit / PushKit VoIP calling (iOS). Config-gated so it can be flipped WITHOUT an app rebuild:
// OFF (default) → calls use the WebView flow (works today); ON → iOS rings via CallKit + a VoIP push.
// Only turn ON once native LiveKit media carries the call audio — a CallKit call reserves the mic, so the
// WebView's WebRTC can't capture it (mic dead). Set CALLKIT_ENABLED=1 in the server .env to enable.
const CALLKIT_ENABLED = process.env.CALLKIT_ENABLED === '1';
module.exports = {
PORT: process.env.PORT || 8090,
HTTPS_PORT: process.env.HTTPS_PORT || 8443,
@@ -58,6 +64,7 @@ module.exports = {
SMTP_ENABLED,
PUBLIC_BASE_URL,
GIPHY_API_KEY,
CALLKIT_ENABLED,
PUBLIC_DIR,
REC_DIR,
TRANS_DIR,
+4
View File
@@ -3785,6 +3785,10 @@ function nativeCallPlugin(){ const P=window.Capacitor&&window.Capacitor.Plugins;
function nativeCallOn(){ return _callkitReady; }
async function setupNativeCall(){
const NC=nativeCallPlugin(); if(!NC) return;
// Server kill-switch: only take over calls with CallKit when the server enables it (callkit:true). When
// off, calls use the WebView flow (mic works). Flipped on only once native media carries the audio.
let cfg={}; try{ cfg=await fetch('/api/meetings/config').then(r=>r.json()); }catch(_){}
if(!cfg || !cfg.callkit){ console.log('[callkit] disabled by server — using WebView calls'); return; }
_callkitReady=true;
// VoIP (PushKit) token → register as an 'ios-voip' device so the server sends CallKit wake pushes.
NC.addListener('voipToken', (e)=>{ const token=e&&e.token; if(!token) return; postJSON('/api/v1/devices',{ platform:'ios-voip', token }).then(()=>console.log('[callkit] voip token registered')).catch((err)=>console.warn('[callkit] voip register failed', err)); });
+2 -2
View File
@@ -138,7 +138,7 @@ async function sendCallNotification(userId, data) {
let toks = [];
try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
const voip = toks.filter((t) => t.platform === 'ios-voip');
if (voip.length && apnsCfg) {
if (voip.length && apnsCfg && process.env.CALLKIT_ENABLED === '1') { // kill-switch: off → fall through to a banner
const payload = {
type: 'invite',
callUUID: data.callUUID, room: data.room, kind: data.kind,
@@ -161,7 +161,7 @@ async function sendCallNotification(userId, data) {
// VoIP push so it reaches a killed app that has no WebSocket — the CallKit plugin ends the reported call.
// No-op for non-VoIP devices (their ring is a normal notification that just goes away).
async function sendCallCancel(userId, callUUID) {
if (!apnsCfg || !callUUID) return;
if (!apnsCfg || !callUUID || process.env.CALLKIT_ENABLED !== '1') return;
let toks = [];
try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
for (const t of toks.filter((t) => t.platform === 'ios-voip')) {
+2 -2
View File
@@ -116,7 +116,7 @@ const API_KEY_SCOPES = ['report:read', 'audit:read'];
const { onlineAgents, meetingRooms, groupCalls, dmCalls } = require('./presence');
const CALLS = require('./calls');
require('./reminders'); // start the 10-minute meeting-reminder loop
const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL, LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED, PUBLIC_BASE_URL, GIPHY_API_KEY } = require('./config');
const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL, LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED, PUBLIC_BASE_URL, GIPHY_API_KEY, CALLKIT_ENABLED } = require('./config');
const https = require('https');
// Small GET-JSON helper for the GIPHY proxy (keeps the key server-side).
function fetchJSON(url) {
@@ -995,7 +995,7 @@ route('POST', '/api/calls/invite', async (req, res) => {
// Does this deployment use the LiveKit SFU for meeting media? The client asks on load; if sfu is
// false it uses the built-in P2P mesh. url is the browser-facing signaling endpoint (wss://…).
route('GET', '/api/meetings/config', (req, res) => {
json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '' });
json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '', callkit: CALLKIT_ENABLED });
});
// #5 GIF search — server-side GIPHY proxy so the API key never reaches the browser. Empty q → trending.