From e60e80e4bdb43aa02cfca3a8951b424e8d637699 Mon Sep 17 00:00:00 2001 From: sravan Date: Thu, 30 Jul 2026 16:32:13 +0530 Subject: [PATCH] feat(calls): native-call lifecycle endpoints + WebView steps aside (inc 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the WebView/server side of native LiveKit calls: - routes.js: /api/calls/answered (markDmAnswered) + /api/calls/end (endCallByRoom) so the server learns a NATIVE call was answered/ended (native media runs over LiveKit, bypassing our mesh/WS lifecycle). Additive no-ops for WebView/mesh calls. - home.html: for native calls the WebView no longer joins the room (one connection per identity — the plugin holds it). answerCall -> POST /api/calls/answered + clear invite; endCall -> /api/calls/end (answered) or /api/calls/decline (still ringing). Outgoing DM/group calls fetch a LiveKit token and hand it to NativeCall.reportOutgoingCall instead of enterMeeting. Removed the old callHandoff mic-repush. Server deploys now; the plugin (native LiveKit) needs a Codemagic build. Still gated by CALLKIT_ENABLED=0 — flip to 1 only after the build is installed. Co-Authored-By: Claude Opus 4.8 --- server/public/home.html | 44 ++++++++++++++++++++--------------------- server/routes.js | 20 +++++++++++++++++++ 2 files changed, 42 insertions(+), 22 deletions(-) diff --git a/server/public/home.html b/server/public/home.html index 1253053..162bc12 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -2546,7 +2546,7 @@ function refreshGroupRowTick(gid){ } // Shared group call: start it (or join the live one — the server returns the existing room). async function startOrJoinGroupCall(group){ - try{ const r=await postJSON('/api/groups/call/start',{ group }); if(r&&r.room){ meetReturn={kind:'group',id:group}; switchTab('meeting'); enterMeeting(r.room); /* Outgoing stays on the WebView (no CallKit) so the mic works. */ } } + try{ const r=await postJSON('/api/groups/call/start',{ group }); if(r&&r.room){ const _g=rowFor('group',group); if(nativeCallOn()){ await callkitReportOutgoing(r.uuid, r.room, 'group', (_g&&_g.name)||'Group call', false); return; } /* native: CallKit + native LiveKit, no WebView join */ meetReturn={kind:'group',id:group}; switchTab('meeting'); enterMeeting(r.room); } } catch(e){ toast(e.message||'Could not start the call'); } } function updateCallBtn(active){ const cc=document.getElementById('convoCall'); if(!cc) return; cc.classList.toggle('joinable',active); cc.title=active?'Join call':'Start call'; cc.innerHTML=ic(active?'video':'phone',18)+(active?'Join':''); } @@ -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); _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. Outgoing stays on the WebView (no CallKit) so the mic works. */ } } + 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 */ } } 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 @@ -3793,34 +3793,34 @@ async function setupNativeCall(){ // 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(_){} - // User answered on the CallKit screen → join that call's room (CallKit already foregrounded the app). + // 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 + // 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{ if(!d||!d.room) return; + _nativeAnswered.add(d.room); dismissCallInvite(d.room); - meetReturn = (d.kind==='group') ? {kind:'group', id:d.groupId} : {kind:'dm', id:d.callerId}; - switchTab('meeting'); enterMeeting(d.room); + postJSON('/api/calls/answered',{ room:d.room }).catch(()=>{}); }catch(e){ console.warn('[callkit] answer failed', e); } }); - // User declined/ended on the CallKit screen. + // 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; - if(room && (typeof meetRoom!=='undefined') && meetRoom===room){ leaveMeeting(); } // already in it → leave - else if(room){ postJSON('/api/calls/decline',{room}).catch(()=>{}); dismissCallInvite(room); } // ringing, not joined → decline + const room=d&&d.room; if(!room) return; + dismissCallInvite(room); + if(_nativeAnswered.has(room)){ _nativeAnswered.delete(room); postJSON('/api/calls/end',{ room }).catch(()=>{}); } + else { postJSON('/api/calls/decline',{ room }).catch(()=>{}); } }catch(_){} }); - // After answer, CallKit releases the mic + audio session back to us (an active CallKit call reserves the - // mic → dead mic + earpiece). Force the loudspeaker (CallKit used the earpiece), then RE-ACQUIRE the mic - // (it was captured dead while CallKit held it). Retry the mic toggle a few times in case the WebView is - // still joining the room when the handoff fires. - NC.addListener('callHandoff', async ()=>{ - try{ const ar=nativeAudioRoute(); if(ar) await ar.setSpeaker({on:true}); }catch(_){} - for(let i=0;i<5;i++){ - try{ const lp=(typeof SFU!=='undefined'&&SFU.room&&SFU.room.localParticipant)||null; if(lp && typeof sfuSetMic==='function'){ await sfuSetMic(false); await sfuSetMic(true); break; } }catch(_){} - await new Promise(r=>setTimeout(r,700)); - } - }); console.log('[callkit] native calling ready'); } -// Register an OUTGOING call with CallKit so it's a system call too (→ background audio for outgoing calls). -function callkitReportOutgoing(uuid, room, kind, peerName, hasVideo){ const NC=nativeCallPlugin(); if(!NC||!uuid) return; try{ NC.reportOutgoingCall({ callUUID:uuid, room, kind:kind||'dm', peerName:peerName||'Call', hasVideo:!!hasVideo }); }catch(_){} } +// 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){ + const NC=nativeCallPlugin(); if(!NC||!uuid) return; + let tk={}; try{ tk=await postJSON('/api/meetings/token',{ room }); }catch(_){} + 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(_){} } diff --git a/server/routes.js b/server/routes.js index aae271d..55fd644 100644 --- a/server/routes.js +++ b/server/routes.js @@ -1074,6 +1074,26 @@ route('POST', '/api/calls/decline', async (req, res) => { json(res, 200, await CALLS.declineDmCall(String(room), u)); }); +// --- Native-call lifecycle. NATIVE (CallKit + LiveKit) calls carry media over LiveKit, NOT our mesh/WS, so +// the server can't learn from a room emptying that a native call was answered/ended. The app signals it +// explicitly. (WebView/mesh calls keep using the WS room lifecycle — these are additive no-ops there.) --- +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); + if (!room) return json(res, 400, { error: 'room required' }); + try { CALLS.markDmAnswered(String(room), u.id); } catch (_) {} + json(res, 200, { ok: true }); +}); +route('POST', '/api/calls/end', async (req, res) => { + const u = await currentUser(req); + if (!u) return json(res, 401, { error: 'unauthorized' }); + const { room } = await readBody(req); + if (!room) return json(res, 400, { error: 'room required' }); + try { await CALLS.endCallByRoom(String(room)); } catch (_) {} + json(res, 200, { ok: true }); +}); + // Toggle "only admins can add/remove members" (any admin). route('POST', '/api/groups/admin-only', async (req, res) => { const u = await currentUser(req);