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:
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user