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

+17 -6
View File
@@ -203,23 +203,34 @@ async function sendCallNotification(userId, data) {
return 'push';
}
// Tell a user's device(s) to STOP ringing a call (caller hung up / declined / ring timed out). Sent as a
// 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) {
// Tell a user's device(s) to STOP ringing a call (caller hung up / declined / ring timed out / answered on
// another device). Sent as a VoIP push (iOS) / high-priority data message (Android) so it reaches a killed app
// that has no WebSocket — the native plugin ends/dismisses the reported call. No-op for non-native devices
// (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;
const exceptToken = opts && opts.exceptToken;
const reason = opts && opts.reason;
let toks = [];
try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
// iOS: cancel the CallKit ring via a VoIP push.
if (apnsCfg && process.env.CALLKIT_ENABLED === '1') {
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).
if (fcmSA && process.env.ANDROID_CALLS === '1') {
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 (_) {}
}
}
}