From e7c3231c50d5407c3c617b842e92a9a417efd63b Mon Sep 17 00:00:00 2001 From: sravan Date: Wed, 29 Jul 2026 17:54:21 +0530 Subject: [PATCH] feat(calls): CallKit kill-switch + fix ~1s re-ring on cancel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Sources/NativeCallPlugin/NativeCallPlugin.swift | 11 +++++++++++ server/config.js | 7 +++++++ server/public/home.html | 4 ++++ server/push.js | 4 ++-- server/routes.js | 4 ++-- 5 files changed, 26 insertions(+), 4 deletions(-) diff --git a/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift b/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift index bca2a1b..69d8de4 100644 --- a/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift +++ b/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift @@ -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() 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() } diff --git a/server/config.js b/server/config.js index e1d608f..212c6d2 100644 --- a/server/config.js +++ b/server/config.js @@ -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, diff --git a/server/public/home.html b/server/public/home.html index 92af6eb..9e16071 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -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)); }); diff --git a/server/push.js b/server/push.js index 752677d..e633ec9 100644 --- a/server/push.js +++ b/server/push.js @@ -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')) { diff --git a/server/routes.js b/server/routes.js index 6f7661c..cf511d0 100644 --- a/server/routes.js +++ b/server/routes.js @@ -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.