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 <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
+14
-1
@@ -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);
|
||||
|
||||
+59
-8
@@ -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='<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;text-align:center">'
|
||||
+'<div id="nc-ava" style="width:118px;height:118px;border-radius:50%;background:#374151;display:flex;align-items:center;justify-content:center;font-size:44px;font-weight:600;overflow:hidden"></div>'
|
||||
+'<div id="nc-name" style="font-size:23px;font-weight:600"></div>'
|
||||
+'<div id="nc-state" style="font-size:15px;opacity:.72;font-variant-numeric:tabular-nums"></div></div>'
|
||||
+'<div style="display:flex;gap:30px;align-items:center;justify-content:center">'
|
||||
+'<button id="nc-mute" style="width:64px;height:64px;border-radius:50%;border:none;background:#374151;color:#fff;font-size:11px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;cursor:pointer"></button>'
|
||||
+'<button id="nc-end" style="width:72px;height:72px;border-radius:50%;border:none;background:#ef4444;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center"></button></div>';
|
||||
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)+'<span>'+(_ncCall.muted?'Unmute':'Mute')+'</span>'; 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 ? ('<img src="'+pEsc(o.avatar)+'" style="width:100%;height:100%;object-fit:cover">') : 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(_){}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user