feat(ios): native CallKit + PushKit VoIP calling plugin + web bridge

The native call feature (iOS). Backward-compatible: without the plugin (current
builds) nativeCallOn() is false and every CallKit branch is skipped, so web/older
builds behave exactly as before.

Native (mobile/plugins/native-call, a local Capacitor plugin like audio-route):
- PushKit: registers for VoIP pushes, reports the VoIP token to JS (-> /api/v1/devices
  'ios-voip'). On an incoming VoIP push, reports a CallKit incoming call (full-screen
  ring, works when the app is force-killed).
- CallKit: answer/decline/end -> events to JS; configures the call AVAudioSession on
  didActivate so the WebView's WebRTC audio rides a call-priority session (background).
- Outgoing calls register with CallKit too (reportOutgoingCall) so they get the same
  active-call background-audio context.
- NativeCall.podspec (frameworks CallKit/PushKit/AVFoundation); added to mobile deps;
  ios-patch.sh now sets UIBackgroundModes = [audio, voip] (voip required for PushKit).

Web bridge (home.html): setupNativeCall() registers the VoIP token, joins on CallKit
answer, leaves/declines on CallKit end; on CallKit devices the in-app call-invite popup
+ WebAudio ring are suppressed (the system rings instead); outgoing calls are reported
to CallKit; call-end events dismiss the CallKit call. calls.js threads a stable call
uuid through the dm-call/group-call WS events + start responses so both sides can match
the CallKit call.

Needs a Codemagic build to compile the plugin; first on-device iteration expected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 22:34:27 +05:30
parent fd2eb42e25
commit f63aba0ed1
7 changed files with 289 additions and 19 deletions
+45 -7
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); } }
try{ const r=await postJSON('/api/groups/call/start',{ group }); if(r&&r.room){ meetReturn={kind:'group',id:group}; const _g=rowFor('group',group); if(nativeCallOn()) callkitReportOutgoing(r.uuid, r.room, 'group', (_g&&_g.name)||'Group call', true); 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>':''); }
@@ -2554,14 +2554,15 @@ function onGroupCall(d){
if(!d||!d.group) return; const it=rowFor('group',d.group); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null;
if(d.active && d.by && d.by!==ME.id){ it.incomingRoom=d.room; it.callByName=d.startedByName; } else if(!d.active){ it.incomingRoom=null; it.callByName=null; } }
if(selected&&selected.kind==='group'&&selected.id===d.group) updateCallBtn(!!d.active);
if(d.active && d.by && d.by!==ME.id && meetRoom!==d.room) showCallInvite(d.room, d.startedByName, {kind:'group',id:d.group}, d.groupName||(it&&it.name)||'Group'); // ring members in
if(!d.active) dismissCallInvite(d.room); // call ended — stop ringing
// Ring members in. On a CallKit device the system rings it (VoIP push) — skip the in-app popup.
if(d.active && d.by && d.by!==ME.id && meetRoom!==d.room && !nativeCallOn()) showCallInvite(d.room, d.startedByName, {kind:'group',id:d.group}, d.groupName||(it&&it.name)||'Group');
if(!d.active){ dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); } // ended → stop ringing + clear CallKit
renderChats(searchVal());
}
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 */ } }
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}; if(nativeCallOn()) callkitReportOutgoing(r.uuid, r.room, 'dm', (_r&&_r.name)||'Call', false); 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
@@ -2579,8 +2580,10 @@ function onDmCall(d){
if(!d) return; const it=rowFor('dm', d.with); if(it){ it.callActive=!!d.active; it.callRoom=d.room||null; if(!d.active && it.status==='incall') it.status='active'; // #4: drop the stuck "in call" status
if(d.active && d.by && d.by!==ME.id){ it.incomingRoom=d.room; it.callByName=d.byName; } else if(!d.active){ it.incomingRoom=null; it.callByName=null; } } // remember an incoming call so opening the chat can re-show Join
if(selected&&selected.kind==='dm'&&selected.id===d.with){ updateCallBtn(!!d.active); const s=document.querySelector('#convoTitle .st'); if(s&&it) s.textContent=dmSubLabel(it); } // refresh the header subtitle live
if(d.active && d.by && d.by!==ME.id) showCallInvite(d.room, d.byName, {kind:'dm',id:d.with}); // incoming 1:1 call
if(!d.active) dismissCallInvite(d.room); // call ended/declined — stop ringing
// Incoming 1:1 call. On a CallKit device the SYSTEM rings it (via the VoIP push) — don't also show the
// in-app popup, and let CallKit answer drive the join. Off CallKit, show the in-app invite as before.
if(d.active && d.by && d.by!==ME.id && !nativeCallOn()) showCallInvite(d.room, d.byName, {kind:'dm',id:d.with});
if(!d.active){ dismissCallInvite(d.room); if(nativeCallOn() && d.uuid) callkitEnd(d.uuid); } // ended/declined → stop ringing + clear CallKit
renderChats(searchVal());
}
// Incoming-call banner (1:1 call or an add-participant invite) with Join / Dismiss.
@@ -3195,7 +3198,7 @@ async function openConvo(kind,id){
// If this conversation has an active INCOMING call, (re)show the Join/Decline invite. When the chat
// is opened from a call notification, the original transient invite popup may already have closed —
// this guarantees a landing Join button. `quiet` avoids firing a duplicate OS notification.
if(it.callActive && it.incomingRoom && meetRoom!==it.incomingRoom && !document.getElementById('ci-'+it.incomingRoom)){
if(it.callActive && it.incomingRoom && meetRoom!==it.incomingRoom && !document.getElementById('ci-'+it.incomingRoom) && !nativeCallOn()){ // CallKit devices ring via the system, not this popup
showCallInvite(it.incomingRoom, it.callByName||it.name, kind==='group'?{kind:'group',id}:{kind:'dm',id}, kind==='group'?it.name:undefined, true);
}
const csb=document.getElementById('convoSearch'); const cshead=document.getElementById('convoSearchHead'); const csin=document.getElementById('convoSearchInput');
@@ -3772,6 +3775,40 @@ async function unsubscribePush(){
pushActive=false; console.log('[push] unsubscribed (logout)');
}catch(_){}
}
// ---------- Native calling (iOS CallKit + PushKit VoIP) ----------
// The native-call plugin rings incoming calls via CallKit (full-screen, works when the app is killed) and
// reports its VoIP token; on answer/end it fires events we bridge into the existing meeting join/leave. On
// 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;
function nativeCallPlugin(){ const P=window.Capacitor&&window.Capacitor.Plugins; return (P&&P.NativeCall)||null; }
function nativeCallOn(){ return _callkitReady; }
async function setupNativeCall(){
const NC=nativeCallPlugin(); if(!NC) return;
_callkitReady=true;
// 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).
NC.addListener('answerCall', (d)=>{ try{
if(!d||!d.room) return;
dismissCallInvite(d.room);
meetReturn = (d.kind==='group') ? {kind:'group', id:d.groupId} : {kind:'dm', id:d.callerId};
switchTab('meeting'); enterMeeting(d.room);
}catch(e){ console.warn('[callkit] answer failed', e); } });
// User declined/ended on the CallKit screen.
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
}catch(_){} });
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(_){} }
// 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(_){} }
// 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.
@@ -5661,6 +5698,7 @@ window.addEventListener('message',(e)=>{
reportInstall(); // desktop/mobile shell: record this install against the signed-in user
wireUpdateBanner(); // #3: desktop update-progress banner
setupPush(); // register the notification service worker + subscribe to Web Push (if granted)
setupNativeCall(); // iOS CallKit: register VoIP token + bridge answer/end to the meeting join/leave
{ const cl=document.getElementById('chatlist'); if(cl) enablePullRefresh(cl, loadSidebar); } // pull-to-refresh the chat list
setTimeout(maybeNotifPrompt, 1500); // gentle "enable notifications" prompt if still undecided (key for iOS PWA)
// Fast-path: when opened from a notification, show the chat immediately (only needs the