From 11f72b259264415789586cddcdc2908db88db341 Mon Sep 17 00:00:00 2001 From: sravan Date: Thu, 30 Jul 2026 18:23:21 +0530 Subject: [PATCH] Native calls: fix answered-call drop, multi-device ring, add in-app call screen Root cause of "answered but the call disconnects": native calls carry media over LiveKit and bypass the mesh, so the server only learned "answered" from a WebView POST. On a cold/locked answer the app is still launching and that event was lost, so the 40s unanswered timer fired and cancelled the live call. Fix: - Plugin: notifyListeners("answerCall", retainUntilConsumed:true) so a killed/ locked pickup isn't lost before the WebView JS attaches. - Server markDmAnswered: emit call-taken to the callee's OTHER devices (stop the ring; no teardown) and call-answered to the caller (flip UI to connected). - Server declineDmCall: ignore a decline once the call is answered, so dismissing a stale ring on a second device can't kill the live call. Also adds a UI-only in-app call screen for native calls (caller + callee): avatar, name, live timer, mute (-> plugin), end (-> CallKit). Native media has no meeting window of its own; this covers "no meeting window / can't unmute". Co-Authored-By: Claude Opus 4.8 --- .../NativeCallPlugin/NativeCallPlugin.swift | 5 +- server/calls.js | 15 ++++- server/public/home.html | 67 ++++++++++++++++--- 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift b/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift index 18b4933..6efce5c 100644 --- a/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift +++ b/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift @@ -195,7 +195,10 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega // NATIVELY so the mic works with the active CallKit call and audio survives backgrounding. connectRoom(url: (data["livekitUrl"] as? String) ?? "", token: (data["livekitToken"] as? String) ?? "") var ev = data; ev["callUUID"] = uuid.uuidString - notifyListeners("answerCall", data: ev) + // Answering from a KILLED/locked state launches the app — the WebView's JS listener may not be attached + // yet, so retain the event until it is. Without this the "answered" signal is lost, the server's + // unanswered timer fires, and the call is cancelled ~40s after pickup. + notifyListeners("answerCall", data: ev, retainUntilConsumed: true) } public func provider(_ provider: CXProvider, perform action: CXEndCallAction) { diff --git a/server/calls.js b/server/calls.js index bfe1bfb..b687daf 100644 --- a/server/calls.js +++ b/server/calls.js @@ -162,7 +162,16 @@ async function endDmCallByRoom(room, silent) { function markDmAnswered(room, userId) { const key = roomToDmCall.get(room); if (!key) return; const call = dmCalls.get(key); if (!call) return; - if (userId && userId !== call.startedBy && !call.answered) { call.answered = true; call.answeredAt = now(); if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} call.ringTimer = null; } } + if (userId && userId !== call.startedBy && !call.answered) { + call.answered = true; call.answeredAt = now(); + if (call.ringTimer) { try { clearTimeout(call.ringTimer); } catch (_) {} call.ringTimer = null; } + // Native calls carry media over LiveKit, not our mesh — so the callee's OTHER devices never learn the + // call was picked up here and keep ringing forever (and dismissing that stale ring as a "decline" would + // tear down THIS live call). Tell them it was taken (dismiss the ring, no teardown), and flip the + // 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 (_) {} + } } // When a user's chat socket (re)connects, re-send any call they're currently being rung into. The // original dm-call / group-call events fire ONCE at call start, so an app that was closed then misses @@ -195,6 +204,10 @@ async function endCallByRoom(room) { await endGroupCallByRoom(room); await endDm async function declineDmCall(room, byUser) { const key = roomToDmCall.get(room); if (!key) return { ok: false }; const call = dmCalls.get(key); if (!call) return { ok: false }; + // Already ANSWERED (e.g. a native CallKit pickup on this user's other device, which bypasses our mesh so + // the check below can't see it): a "decline" here is just the stale ring on a second device — dismiss it, + // do NOT tear down the live call. + if (call.answered && byUser.id !== call.startedBy) return { ok: true, alreadyAnswered: true }; // #8: if this user has ALREADY accepted on another device (they're in the room), this is just the // ringing invite on a second device — dismiss it silently, do NOT tear down the active call. const inRoom = meetingRooms.get(room); diff --git a/server/public/home.html b/server/public/home.html index aab4f8c..0c61a24 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -2562,7 +2562,7 @@ function onGroupCall(d){ function dismissCallInvite(room){ if(!room) return; const el=document.getElementById('ci-'+room); if(el){ try{ el.remove(); }catch(_){} stopRing(); } } // 1:1 call: start/join from the DM header; live state updates the button + shows an incoming invite. async function startOrJoinDmCall(otherId){ - try{ const r=await postJSON('/api/calls/dm/start',{ to:otherId }); if(r&&r.room){ const _r=rowFor('dm',otherId); if(nativeCallOn()){ await callkitReportOutgoing(r.uuid, r.room, 'dm', (_r&&_r.name)||'Call', false); return; } /* native: CallKit + native LiveKit, no WebView join */ _dmCallWaiting={ name:(_r&&_r.name)||'Contact', avatar:(_r&&_r.avatar)||null }; meetReturn={kind:'dm',id:otherId}; switchTab('meeting'); enterMeeting(r.room); startRingback(); /* #17: ring until they answer */ } } + try{ const r=await postJSON('/api/calls/dm/start',{ to:otherId }); if(r&&r.room){ const _r=rowFor('dm',otherId); if(nativeCallOn()){ await callkitReportOutgoing(r.uuid, r.room, 'dm', (_r&&_r.name)||'Call', false, (_r&&_r.avatar)||null); return; } /* native: CallKit + native LiveKit, no WebView join */ _dmCallWaiting={ name:(_r&&_r.name)||'Contact', avatar:(_r&&_r.avatar)||null }; meetReturn={kind:'dm',id:otherId}; switchTab('meeting'); enterMeeting(r.room); startRingback(); /* #17: ring until they answer */ } } catch(e){ toast(e.message||'Could not start the call'); } } // Live presence: a contact came online/offline or entered/left a call — update their dot + the @@ -3781,6 +3781,7 @@ async function unsubscribePush(){ // a CallKit device the SYSTEM owns the incoming ring, so we suppress the in-app call-invite popup // (onDmCall / the group invite check nativeCallOn()). let _callkitReady=false; +let _nativeAnswered=new Set(); // rooms this device answered natively (so a "call-taken" from the server doesn't end our own call) function nativeCallPlugin(){ const P=window.Capacitor&&window.Capacitor.Plugins; return (P&&P.NativeCall)||null; } function nativeCallOn(){ return _callkitReady; } async function setupNativeCall(){ @@ -3797,24 +3798,31 @@ async function setupNativeCall(){ // (LiveKit = one connection per identity). So on answer we do NOT enterMeeting — we just tell the server // the call was answered (native media bypasses our WS, so the server can't learn it otherwise) and clear // the in-app invite. Track answered rooms so end vs decline is signalled correctly. - const _nativeAnswered = new Set(); NC.addListener('answerCall', (d)=>{ try{ pdbg('nc-answer', { room:(d&&d.room)||'', hasUrl:!!(d&&d.livekitUrl), hasToken:!!(d&&d.livekitToken) }); if(!d||!d.room) return; _nativeAnswered.add(d.room); dismissCallInvite(d.room); postJSON('/api/calls/answered',{ room:d.room }).catch(()=>{}); + // Show the in-app call screen for the callee (native media has no meeting window of its own). + const isGroup=(d.kind==='group'); const c=(CONTACTS||[]).find(x=>x.id===d.callerId); + ncShowCall({ role:'callee', room:d.room, uuid:d.callUUID, video:!!d.hasVideo, + name: isGroup ? (d.groupName||'Group call') : (d.callerName||(c&&c.name)||'Call'), + avatar: isGroup ? null : (c&&c.avatar)||null, state:'Connecting…' }); }catch(e){ pdbg('nc-answer-err', { err:String((e&&e.message)||e) }); } }); - // TEMP native-call telemetry: the plugin fires these on its LiveKit connection so we can see, from server - // logs, whether the native room actually connected (no device console available). - NC.addListener('callConnected', ()=>{ pdbg('nc-connected'); }); - NC.addListener('callError', (e)=>{ pdbg('nc-error', { err:(e&&e.error)||'' }); }); + // The plugin fires callConnected once its LiveKit room connects. For the CALLEE that IS the pickup → mark + // the in-app screen connected + start the timer. (The caller flips on the server's "call-answered".) + NC.addListener('callConnected', ()=>{ pdbg('nc-connected'); if(_ncCall && _ncCall.role==='callee') ncMarkConnected(); }); + NC.addListener('callError', (e)=>{ pdbg('nc-error', { err:(e&&e.error)||'' }); if(_ncCall){ ncSetState('Call failed'); setTimeout(()=>{ if(_ncCall) ncHideCall(); }, 1500); } }); + // Keep the in-app mute button in sync when muted from the CallKit system UI. + NC.addListener('setMuted', (e)=>{ if(_ncCall && e && typeof e.muted!=='undefined') ncSetMuted(!!e.muted, false); }); // Ended/declined on the CallKit screen (or the plugin ended it on remote hang-up). Tell the server: END an // answered call, DECLINE one that was still ringing. NC.addListener('endCall', (d)=>{ try{ const room=d&&d.room; pdbg('nc-end', { room:room||'', answered:_nativeAnswered.has(room) }); if(!room) return; dismissCallInvite(room); + if(_ncCall && _ncCall.room===room) ncHideCall(); if(_nativeAnswered.has(room)){ _nativeAnswered.delete(room); postJSON('/api/calls/end',{ room }).catch(()=>{}); } else { postJSON('/api/calls/decline',{ room }).catch(()=>{}); } }catch(_){} }); @@ -3822,15 +3830,58 @@ async function setupNativeCall(){ } // Start an OUTGOING native call: fetch a LiveKit join token for the room, then have the plugin start the // CallKit call AND connect the LiveKit room natively (the WebView does NOT join — one connection per identity). -async function callkitReportOutgoing(uuid, room, kind, peerName, hasVideo){ +async function callkitReportOutgoing(uuid, room, kind, peerName, hasVideo, avatar){ const NC=nativeCallPlugin(); if(!NC||!uuid) return; let tk={}; try{ tk=await postJSON('/api/meetings/token',{ room }); }catch(_){} pdbg('nc-outgoing', { room, hasUrl:!!(tk&&tk.url), hasToken:!!(tk&&tk.token) }); + ncShowCall({ role:'caller', room, uuid, video:!!hasVideo, name:peerName||'Call', avatar:avatar||null, state:'Calling…' }); try{ NC.reportOutgoingCall({ callUUID:uuid, room, kind:kind||'dm', peerName:peerName||'Call', hasVideo:!!hasVideo, url:tk.url||'', token:tk.token||'' }); }catch(_){} } // End the CallKit call (remote hung up / we left / call ended). Safe no-op off-CallKit. function callkitEnd(uuid){ const NC=nativeCallPlugin(); if(!NC||!uuid) return; try{ NC.endCall({ callUUID:uuid }); }catch(_){} } +// --- In-app call screen for NATIVE calls. Native media runs over LiveKit (no mesh/WebView meeting window), +// so this is a UI-only overlay: it shows who you're on with + a timer, and drives mute/end through the +// plugin (CallKit stays the source of truth). Shown for both the caller and the callee. --- +let _ncCall=null; // { role, room, uuid, name, avatar, video, muted, connected, t0, timer } +function ncFmt(s){ s=Math.max(0,Math.floor(s)); const m=Math.floor(s/60), ss=String(s%60).padStart(2,'0'); return m+':'+ss; } +function ncEnsureEl(){ + let el=document.getElementById('nc-call'); if(el) return el; + el=document.createElement('div'); el.id='nc-call'; + el.style.cssText='position:fixed;inset:0;z-index:100000;display:none;flex-direction:column;align-items:center;justify-content:space-between;background:linear-gradient(165deg,#0b1220,#111827 55%,#0b1220);color:#fff;padding:calc(env(safe-area-inset-top,0px) + 52px) 20px calc(env(safe-area-inset-bottom,0px) + 40px);'; + el.innerHTML='
' + +'
' + +'
' + +'
' + +'
' + +'' + +'
'; + document.body.appendChild(el); + el.querySelector('#nc-end').innerHTML=ic('callEnd',26); + el.querySelector('#nc-end').onclick=()=>{ const u=_ncCall&&_ncCall.uuid; ncHideCall(); if(u) callkitEnd(u); }; + el.querySelector('#nc-mute').onclick=()=>{ if(_ncCall) ncSetMuted(!_ncCall.muted, true); }; + return el; +} +function ncRenderMute(){ const b=document.getElementById('nc-mute'); if(!b||!_ncCall) return; b.innerHTML=ic(_ncCall.muted?'micOff':'mic',22)+''+(_ncCall.muted?'Unmute':'Mute')+''; b.style.background=_ncCall.muted?'#fff':'#374151'; b.style.color=_ncCall.muted?'#111827':'#fff'; } +function ncSetMuted(m, push){ if(!_ncCall) return; _ncCall.muted=!!m; ncRenderMute(); if(push){ const NC=nativeCallPlugin(); if(NC){ try{ NC.setMuted({ muted:!!m }); }catch(_){} } } } +function ncSetState(txt){ const s=document.getElementById('nc-state'); if(s) s.textContent=txt||''; } +function ncTick(){ if(!_ncCall||!_ncCall.connected) return; ncSetState(ncFmt((Date.now()-_ncCall.t0)/1000)); } +function ncShowCall(o){ + if(!o||!o.room) return; ncEnsureEl(); + _ncCall={ role:o.role||'callee', room:o.room, uuid:o.uuid, name:o.name||'Call', avatar:o.avatar||null, video:!!o.video, muted:false, connected:false, t0:0, timer:null }; + const el=document.getElementById('nc-call'); + el.querySelector('#nc-ava').innerHTML = o.avatar ? ('') : pEsc(((o.name||'?').trim().charAt(0)||'?').toUpperCase()); + el.querySelector('#nc-name').textContent=o.name||'Call'; + ncSetState(o.state||'Connecting…'); ncRenderMute(); + el.style.display='flex'; +} +function ncMarkConnected(){ if(!_ncCall||_ncCall.connected) return; _ncCall.connected=true; _ncCall.t0=Date.now(); if(_ncCall.timer) clearInterval(_ncCall.timer); _ncCall.timer=setInterval(ncTick,1000); ncTick(); } +function ncHideCall(){ const el=document.getElementById('nc-call'); if(el) el.style.display='none'; if(_ncCall&&_ncCall.timer) clearInterval(_ncCall.timer); _ncCall=null; } +// Another of the callee's devices answered — stop ringing here (but never on the device that answered). +function onCallTaken(d){ if(!d||!d.room) return; if(_nativeAnswered.has(d.room)) return; dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); if(_ncCall && _ncCall.room===d.room) ncHideCall(); } +// The callee picked up — flip the caller's in-app screen from "Calling…" to a running timer. +function onCallAnswered(d){ if(!d) return; if(_ncCall && _ncCall.role==='caller' && (!d.room || _ncCall.room===d.room)) ncMarkConnected(); } + // Open the chat from an in-page notification. Navigation reliably repaints across browsers (a // notification click is not an in-page gesture, so an in-place open won't paint until you // tap). The reload is made fast by HTTP caching + a boot fast-path that opens the chat first. @@ -4151,7 +4202,7 @@ function connectChatWs(){ if(_chatReconnectT){ clearTimeout(_chatReconnectT); _chatReconnectT=null; } chatWs=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws'); chatWs.onopen=()=>{ try{ chatWs.send(JSON.stringify({type:'chat-hello'})); }catch(_){} if(_chatConnectedOnce) resyncChat(); _chatConnectedOnce=true; }; - chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-edited') onChatEdited(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='presence') onPresence(d); else if(d.type==='chat-typing') onTyping(d); else if(d.type==='notif-clear') onNotifClear(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); }; + chatWs.onmessage=(e)=>{ let d; try{ d=JSON.parse(e.data); }catch(_){ return; } if(d.type==='chat-message' && d.message) onChatMessage(d.message); else if(d.type==='chat-deleted') onChatDeleted(d); else if(d.type==='chat-edited') onChatEdited(d); else if(d.type==='chat-reaction') onChatReaction(d); else if(d.type==='poll-update' && d.poll) onPollUpdate(d); else if(d.type==='chat-read') onChatRead(d); else if(d.type==='chat-delivered') onChatDelivered(d); else if(d.type==='group-read') onGroupRead(d); else if(d.type==='group-call') onGroupCall(d); else if(d.type==='dm-call') onDmCall(d); else if(d.type==='call-taken') onCallTaken(d); else if(d.type==='call-answered') onCallAnswered(d); else if(d.type==='presence') onPresence(d); else if(d.type==='chat-typing') onTyping(d); else if(d.type==='notif-clear') onNotifClear(d); else if(d.type==='group-update') onGroupUpdate(d); else if(d.type==='call-invite') showCallInvite(d.room, d.byName); else if(d.type==='meeting-invite') showMeetingInvite(d.meeting); else if(d.type==='meeting-reminder') showMeetingReminder(d.meeting); else if(d.type==='meeting-cancelled') showMeetingCancelled(d.meeting); else if(d.type==='group-role') onGroupRole(d); }; chatWs.onclose=()=>{ if(_chatReconnectT) clearTimeout(_chatReconnectT); _chatReconnectT=setTimeout(connectChatWs, 3000); }; // auto-reconnect (single pending timer) }catch(_){} }