call: stop the ring on a user's other devices when one answers (answered-elsewhere)

A user signed in on two devices (iPhone + Android) rang on both. When they
answered on Android, the iPhone kept ringing: markDmAnswered only emitted a
WS 'call-taken', which never reaches a backgrounded/killed device ringing on
CallKit (no live socket).

Now markDmAnswered also pushes a native cancel to the answerer's OTHER
devices. The device that actually answered is skipped via exceptToken (its own
call-push token) — critical on iOS, where a cancel would end the just-answered
CallKit call; Android's cancel branch never tears down a live call anyway.

- push.sendCallCancel(userId, uuid, { exceptToken, reason }) — skips the
  answering device's token; existing 2-arg callers unchanged.
- calls.markDmAnswered(room, userId, exceptToken) — fires the cancel.
- POST /api/calls/answered now takes deviceToken.
- home.html: the native answerCall handler POSTs /api/calls/answered with this
  device's own token (ios-voip or fcm), so native/LiveKit calls (which bypass
  the mesh) are marked answered and siblings stop ringing.

Server + web only — no native build needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
SravanandClaude Opus 4.8 committed 2026-10-30 16:38:51 +05:30
1 parent aa794102c3
commit 08eff043ec
4 files changed
+36 -11

No files matched your search

+7 -1
View File
@@ -171,7 +171,7 @@ async function endDmCallByRoom(room, silent) {
// Mark a 1:1 call answered when the callee (anyone other than the caller) joins its room, so the end // Mark a 1:1 call answered when the callee (anyone other than the caller) joins its room, so the end
// message shows a real duration (from answer) and the unanswered timeout stands down. // message shows a real duration (from answer) and the unanswered timeout stands down.
function markDmAnswered(room, userId) { function markDmAnswered(room, userId, exceptToken) {
const key = roomToDmCall.get(room); if (!key) return; const key = roomToDmCall.get(room); if (!key) return;
const call = dmCalls.get(key); if (!call) return; const call = dmCalls.get(key); if (!call) return;
if (userId && userId !== call.startedBy && !call.answered) { if (userId && userId !== call.startedBy && !call.answered) {
@@ -183,6 +183,12 @@ function markDmAnswered(room, userId) {
// caller's UI from "ringing" to "connected". // caller's UI from "ringing" to "connected".
try { CHAT.pushToUser(userId, { type: 'call-taken', room, uuid: call.uuid }); } catch (_) {} try { CHAT.pushToUser(userId, { type: 'call-taken', room, uuid: call.uuid }); } catch (_) {}
try { CHAT.pushToUser(call.startedBy, { type: 'call-answered', room, uuid: call.uuid, by: userId }); } catch (_) {} try { CHAT.pushToUser(call.startedBy, { type: 'call-answered', room, uuid: call.uuid, by: userId }); } catch (_) {}
// ...but call-taken is WS-only; a backgrounded/killed sibling device ringing via CallKit/FCM has no live
// socket, so it keeps ringing. Push a native cancel to the answerer's OTHER devices (answered-elsewhere).
// exceptToken = the token of the device that actually answered — never cancel it (on iOS a cancel would end
// the just-answered call). Android's cancel branch never tears down a live call, so a stray cancel there is
// harmless; the exclusion still avoids a pointless FCM round-trip.
try { PUSH.sendCallCancel(userId, call.uuid, { exceptToken, reason: 'answered' }); } catch (_) {}
} }
} }
// When a user's chat socket (re)connects, re-send any call they're currently being rung into. The // When a user's chat socket (re)connects, re-send any call they're currently being rung into. The
+8 -2
View File
@@ -4337,6 +4337,7 @@ async function unsubscribePush(){
let _callkitReady=false; let _callkitReady=false;
let _callWebViewMedia=false; // Android Increment 1: native full-screen ring, but MEDIA stays in the WebView (iOS runs the call media natively via the plugin). Governs the answer/outgoing branches below. let _callWebViewMedia=false; // Android Increment 1: native full-screen ring, but MEDIA stays in the WebView (iOS runs the call media natively via the plugin). Governs the answer/outgoing branches below.
let _nativeAnswered=new Set(); // rooms this device answered natively (so a "call-taken" from the server doesn't end our own call) let _nativeAnswered=new Set(); // rooms this device answered natively (so a "call-taken" from the server doesn't end our own call)
let _myVoipTok=''; // this device's own iOS VoIP (ios-voip) token — sent on answer so the answered-elsewhere cancel skips us
function nativeCallPlugin(){ const P=window.Capacitor&&window.Capacitor.Plugins; return (P&&P.NativeCall)||null; } function nativeCallPlugin(){ const P=window.Capacitor&&window.Capacitor.Plugins; return (P&&P.NativeCall)||null; }
function nativeCallOn(){ return _callkitReady; } function nativeCallOn(){ return _callkitReady; }
async function setupNativeCall(){ async function setupNativeCall(){
@@ -4356,8 +4357,8 @@ async function setupNativeCall(){
if(!cfg || !cfg.callkit){ console.log('[callkit] disabled by server — using WebView calls'); return; } if(!cfg || !cfg.callkit){ console.log('[callkit] disabled by server — using WebView calls'); return; }
_callkitReady=true; _callkitReady=true;
// VoIP (PushKit) token → register as an 'ios-voip' device so the server sends CallKit wake pushes. // 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)); }); NC.addListener('voipToken', (e)=>{ const token=e&&e.token; if(!token) return; _myVoipTok=token; postJSON('/api/v1/devices',{ platform:'ios-voip', token }).then(()=>console.log('[callkit] voip token registered')).catch((err)=>console.warn('[callkit] voip register failed', err)); });
try{ const t=await NC.getToken(); if(t&&t.token) postJSON('/api/v1/devices',{ platform:'ios-voip', token:t.token }).catch(()=>{}); }catch(_){} try{ const t=await NC.getToken(); if(t&&t.token){ _myVoipTok=t.token; postJSON('/api/v1/devices',{ platform:'ios-voip', token:t.token }).catch(()=>{}); } }catch(_){}
} }
// Native media: the plugin carries the call over its OWN LiveKit room. The WebView must NOT also join // Native media: the plugin carries the call over its OWN LiveKit room. The WebView must NOT also join
// (LiveKit = one connection per identity). So on answer we do NOT enterMeeting — we just tell the server // (LiveKit = one connection per identity). So on answer we do NOT enterMeeting — we just tell the server
@@ -4366,6 +4367,11 @@ async function setupNativeCall(){
NC.addListener('answerCall', (d)=>{ try{ NC.addListener('answerCall', (d)=>{ try{
if(!d||!d.room) return; if(!d||!d.room) return;
_nativeAnswered.add(d.room); _nativeAnswered.add(d.room);
// Tell the server THIS device answered. Native/LiveKit calls bypass our mesh, so without this the call is
// never marked answered server-side and the answerer's OTHER devices (e.g. an iPhone ringing on CallKit)
// keep ringing. deviceToken = our own call token so the answered-elsewhere cancel skips us (never ends the
// call we just picked up). iOS → the VoIP token; Android → the FCM token.
try{ const _ct=(nativePlatform()==='ios')?_myVoipTok:_nativeTok; postJSON('/api/calls/answered',{ room:d.room, deviceToken:_ct||'' }).catch(()=>{}); }catch(_){}
dismissCallInvite(d.room); dismissCallInvite(d.room);
// Open the REAL meeting window. Joining the mesh marks the call answered server-side, shows the caller's // Open the REAL meeting window. Joining the mesh marks the call answered server-side, shows the caller's
// tile, and wires the end-both-sides lifecycle. meetReturn = the chat to land back on when the call ends. // tile, and wires the end-both-sides lifecycle. meetReturn = the chat to land back on when the call ends.
+17 -6
View File
@@ -203,23 +203,34 @@ async function sendCallNotification(userId, data) {
return 'push'; return 'push';
} }
// Tell a user's device(s) to STOP ringing a call (caller hung up / declined / ring timed out). Sent as a // Tell a user's device(s) to STOP ringing a call (caller hung up / declined / ring timed out / answered on
// VoIP push so it reaches a killed app that has no WebSocket — the CallKit plugin ends the reported call. // another device). Sent as a VoIP push (iOS) / high-priority data message (Android) so it reaches a killed app
// No-op for non-VoIP devices (their ring is a normal notification that just goes away). // that has no WebSocket — the native plugin ends/dismisses the reported call. No-op for non-native devices
async function sendCallCancel(userId, callUUID) { // (their ring is a normal notification that just goes away).
// opts.exceptToken — skip this exact device token. Used for "answered elsewhere": the device that actually
// answered must NOT be cancelled (on iOS a cancel ends the just-answered CallKit call), only its SIBLINGS.
// opts.reason — optional tag carried in the payload ('answered' etc.), for logs / a future answered-elsewhere
// call-history entry. Native plugins ignore unknown fields, so it's safe to always include.
async function sendCallCancel(userId, callUUID, opts) {
if (!callUUID) return; if (!callUUID) return;
const exceptToken = opts && opts.exceptToken;
const reason = opts && opts.reason;
let toks = []; let toks = [];
try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; } try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
// iOS: cancel the CallKit ring via a VoIP push. // iOS: cancel the CallKit ring via a VoIP push.
if (apnsCfg && process.env.CALLKIT_ENABLED === '1') { if (apnsCfg && process.env.CALLKIT_ENABLED === '1') {
for (const t of toks.filter((t) => t.platform === 'ios-voip')) { for (const t of toks.filter((t) => t.platform === 'ios-voip')) {
try { const r = await sendApnsVoip(t.token, { type: 'cancel', callUUID }); if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) {} if (exceptToken && t.token === exceptToken) continue; // never cancel the device that answered
const payload = reason ? { type: 'cancel', callUUID, reason } : { type: 'cancel', callUUID };
try { const r = await sendApnsVoip(t.token, payload); if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) {}
} }
} }
// Android: cancel the native ring via a high-priority data message (BzCallMessagingService dismisses it). // Android: cancel the native ring via a high-priority data message (BzCallMessagingService dismisses it).
if (fcmSA && process.env.ANDROID_CALLS === '1') { if (fcmSA && process.env.ANDROID_CALLS === '1') {
for (const t of toks.filter((t) => t.platform === 'android')) { for (const t of toks.filter((t) => t.platform === 'android')) {
try { const r = await sendFcmData(t.token, { bzcall: '1', type: 'cancel', callUUID }); if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) {} if (exceptToken && t.token === exceptToken) continue; // never cancel the device that answered
const data = reason ? { bzcall: '1', type: 'cancel', callUUID, reason } : { bzcall: '1', type: 'cancel', callUUID };
try { const r = await sendFcmData(t.token, data); if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) {}
} }
} }
} }
+4 -2
View File
@@ -1120,9 +1120,11 @@ route('POST', '/api/calls/decline', async (req, res) => {
route('POST', '/api/calls/answered', async (req, res) => { route('POST', '/api/calls/answered', async (req, res) => {
const u = await currentUser(req); const u = await currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' }); if (!u) return json(res, 401, { error: 'unauthorized' });
const { room } = await readBody(req); const { room, deviceToken } = await readBody(req);
if (!room) return json(res, 400, { error: 'room required' }); if (!room) return json(res, 400, { error: 'room required' });
try { CALLS.markDmAnswered(String(room), u.id); } catch (_) {} // deviceToken = THIS device's own call-push token (ios-voip / android fcm). Passed so the answered-elsewhere
// cancel sent to the user's other devices skips the one that actually answered.
try { CALLS.markDmAnswered(String(room), u.id, deviceToken ? String(deviceToken) : undefined); } catch (_) {}
json(res, 200, { ok: true }); json(res, 200, { ok: true });
}); });
route('POST', '/api/calls/end', async (req, res) => { route('POST', '/api/calls/end', async (req, res) => {