fix(calls): cancel the CallKit ring when the caller ends before answer

Bug: a killed/backgrounded callee is woken only for the CallKit ring and has no
WebSocket yet, so the existing dm-call active:false (WS-only) never reaches it and
it keeps ringing after the caller hangs up.

Fix: send a 'cancel' VoIP push on call teardown.
- push.js: sendCallCancel() sends a {type:'cancel',callUUID} VoIP push to the user's
  ios-voip tokens; invites now carry type:'invite'.
- calls.js: endDmCallByRoom + endGroupCallByRoom fire sendCallCancel to the rung users.
- NativeCallPlugin: on a cancel push, end the reported call (reportCall endedAt); if the
  invite was never seen, report-then-end to satisfy iOS's 'report a call per VoIP push'.

Server part deploys now; the plugin part needs the next Codemagic build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 11:09:07 +05:30
parent cd7ab74eed
commit 3e21aa30d7
3 changed files with 37 additions and 1 deletions
+14 -1
View File
@@ -140,6 +140,7 @@ async function sendCallNotification(userId, data) {
const voip = toks.filter((t) => t.platform === 'ios-voip');
if (voip.length && apnsCfg) {
const payload = {
type: 'invite',
callUUID: data.callUUID, room: data.room, kind: data.kind,
callerId: data.callerId || '', callerName: data.callerName || 'Incoming call',
groupId: data.groupId || '', groupName: data.groupName || '', hasVideo: !!data.hasVideo,
@@ -156,6 +157,18 @@ 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) {
if (!apnsCfg || !callUUID) return;
let toks = [];
try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
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 (_) {}
}
}
// ---------------- public API ----------------
const nativeReady = !!(fcmSA || apnsCfg);
const enabled = [webReady && 'WebPush', fcmSA && 'FCM', apnsCfg && 'APNs'].filter(Boolean);
@@ -191,4 +204,4 @@ async function sendToUser(userId, payload) {
}
}
module.exports = { isEnabled, publicKey, sendToUser, sendCallNotification };
module.exports = { isEnabled, publicKey, sendToUser, sendCallNotification, sendCallCancel };