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:
@@ -118,6 +118,25 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
|
||||
var dict: [String: Any] = [:]
|
||||
for (k, v) in payload.dictionaryPayload { if let ks = k as? String { dict[ks] = v } }
|
||||
let uuid = UUID(uuidString: (dict["callUUID"] as? String) ?? "") ?? UUID()
|
||||
|
||||
// CANCEL: the caller hung up / declined / it timed out before we answered → stop ringing. iOS still
|
||||
// requires a reported call for every VoIP push, so if we never saw the invite, report then end it.
|
||||
if (dict["type"] as? String) == "cancel" {
|
||||
if calls[uuid] != nil {
|
||||
provider?.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded)
|
||||
calls.removeValue(forKey: uuid)
|
||||
completion()
|
||||
} else {
|
||||
let u = CXCallUpdate()
|
||||
u.remoteHandle = CXHandle(type: .generic, value: "Call")
|
||||
provider?.reportNewIncomingCall(with: uuid, update: u) { [weak self] _ in
|
||||
self?.provider?.reportCall(with: uuid, endedAt: Date(), reason: .remoteEnded)
|
||||
completion()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let callerName = (dict["callerName"] as? String) ?? (dict["groupName"] as? String) ?? "Incoming call"
|
||||
let hasVideo = (dict["hasVideo"] as? Bool) ?? false
|
||||
calls[uuid] = dict
|
||||
|
||||
@@ -89,6 +89,8 @@ async function endGroupCallByRoom(room) {
|
||||
let teamId = call.teamId; try { const g = await R.conversations.byId(group); if (g) { teamId = g.team_id; postSystem(group, g.team_id, '📞 Group call ended · ' + fmtDur(now() - call.startedAt)).catch(() => {}); } } catch (_) {}
|
||||
if (call.historyId && teamId) { try { await R.scheduledMeetings.end(call.historyId, teamId); } catch (_) {} } // mark the history row past
|
||||
broadcast(group, { type: 'group-call', group, active: false, room, uuid: call.uuid });
|
||||
// Stop any CallKit ring on members' killed/backgrounded devices.
|
||||
try { for (const mid of await R.conversations.members(group)) PUSH.sendCallCancel(mid, call.uuid); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +144,8 @@ async function endDmCallByRoom(room, silent) {
|
||||
call.users.forEach((uid) => { try { CHAT.pushToUser(uid, { type: 'chat-message', message: dto }); } catch (_) {} });
|
||||
} catch (_) {}
|
||||
call.users.forEach((uid, i) => { try { CHAT.pushToUser(uid, { type: 'dm-call', active: false, uuid: call.uuid, with: call.users[1 - i], room }); } catch (_) {} });
|
||||
// Stop any CallKit ring on a killed/backgrounded device (no WS to receive the dm-call above).
|
||||
try { for (const uid of call.users) PUSH.sendCallCancel(uid, call.uuid); } catch (_) {}
|
||||
}
|
||||
|
||||
// Mark a 1:1 call answered when the callee (anyone other than the caller) joins its room, so the end
|
||||
|
||||
+14
-1
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user