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:
1 parent
aa794102c3
commit
08eff043ec
4 files changed
+36
-11
No files matched your search
+7
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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) => {
|
||||||
|
|||||||
Reference in new issue
Block a user