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='