diff --git a/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift b/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift index 0ad7111..bca2a1b 100644 --- a/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift +++ b/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift @@ -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 diff --git a/server/calls.js b/server/calls.js index 2c7e6f7..0dbbcc8 100644 --- a/server/calls.js +++ b/server/calls.js @@ -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 diff --git a/server/push.js b/server/push.js index f2156b9..752677d 100644 --- a/server/push.js +++ b/server/push.js @@ -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 };