feat(calls): native-call lifecycle endpoints + WebView steps aside (inc 1)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 16:32:13 +05:30
parent 3e8aaad68c
commit e60e80e4bd
2 changed files with 42 additions and 22 deletions
+22 -22
View File
@@ -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?'<span>Join</span>':''); }
@@ -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(_){} }
+20
View File
@@ -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);