diff --git a/server/calls.js b/server/calls.js index 13530af..67d46f8 100644 --- a/server/calls.js +++ b/server/calls.js @@ -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 // 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 call = dmCalls.get(key); if (!call) return; if (userId && userId !== call.startedBy && !call.answered) { @@ -183,6 +183,12 @@ function markDmAnswered(room, userId) { // caller's UI from "ringing" to "connected". 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 (_) {} + // ...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 diff --git a/server/public/home.html b/server/public/home.html index 5236ea1..41f6f42 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -4337,6 +4337,7 @@ async function unsubscribePush(){ 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 _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 nativeCallOn(){ return _callkitReady; } async function setupNativeCall(){ @@ -4356,8 +4357,8 @@ async function setupNativeCall(){ if(!cfg || !cfg.callkit){ console.log('[callkit] disabled by server — using WebView calls'); return; } _callkitReady=true; // 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)); }); - try{ const t=await NC.getToken(); if(t&&t.token) postJSON('/api/v1/devices',{ platform:'ios-voip', token:t.token }).catch(()=>{}); }catch(_){} + 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){ _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 // (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{ if(!d||!d.room) return; _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); // 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. diff --git a/server/push.js b/server/push.js index 532ede9..b670856 100644 --- a/server/push.js +++ b/server/push.js @@ -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 (_) {} } } } diff --git a/server/routes.js b/server/routes.js index 22601bb..d6e77df 100644 --- a/server/routes.js +++ b/server/routes.js @@ -1120,9 +1120,11 @@ route('POST', '/api/calls/decline', async (req, res) => { route('POST', '/api/calls/answered', async (req, res) => { const u = await currentUser(req); 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' }); - 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 }); }); route('POST', '/api/calls/end', async (req, res) => {