Android native calling — Increment 1: full-screen incoming ring + answer

First slice of Android Phase C (full LiveKit parity), mirroring how iOS
started: native full-screen incoming call UI that wakes the app from
closed, with call MEDIA still in the WebView (foreground). Native LiveKit
media + a foreground service for true background audio are Increment 2.

Native (new Android side of the native-call plugin, Java to match the
proven official-plugin module layout):
- NativeCallPlugin: registered as window.Capacitor.Plugins.NativeCall
  (same jsName as iOS, so the shared web bridge drives both). getToken
  (FCM token), reportIncomingCall (ring), reportOutgoingCall, endCall,
  setMuted; video/screen/hole-punch/transcript methods are safe no-ops
  until later increments so the shared bridge never rejects.
- BzCallMessagingService: extends @capacitor/push-notifications'
  MessagingService — handles high-priority call-wake DATA messages
  (data.bzcall==1) to ring when closed, forwards all other push via super.
- IncomingCallActivity: full-screen call screen over the lock screen
  (showWhenLocked/turnScreenOn), Accept/Decline, looping ringtone.
- CallStore: incoming-call notification (channel, full-screen-intent,
  actions) + UUID dedup so the FCM and WebSocket paths don't double-ring.
- Plugin manifest: BzCallMessagingService + IncomingCallActivity + call
  perms (USE_FULL_SCREEN_INTENT/VIBRATE/WAKE_LOCK).
- android-patch.js: removes Capacitor's MessagingService in the APP
  manifest (highest merge priority) so ours is the sole MESSAGING_EVENT
  receiver; ensures the tools namespace. Idempotent.
- package.json: capacitor.android.src wired.

Server (dormant until ANDROID_CALLS=1):
- push.js sendFcmData: high-priority data-only FCM (rings when closed).
  sendCallNotification rings 'android' tokens via it; sendCallCancel too.
- config.js ANDROID_CALLS flag; /api/meetings/config exposes androidCall.

Web (home.html) — platform-aware, iOS/web behavior byte-for-byte unchanged:
- setupNativeCall: Android gated on cfg.androidCall, sets _callWebViewMedia
  (native ring, WebView media); iOS keeps CallKit native-media path.
- answerCall + both outgoing sites branch on _callWebViewMedia so Android
  joins the meeting normally while the callee still rings via the server.

Safe to deploy now: dormant until ANDROID_CALLS=1 AND an APK carrying the
plugin is installed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
SravanandClaude Opus 4.8 committed 2026-09-25 11:44:52 +05:30
1 parent a8e0f549d4
commit b60ce6cda3
12 files changed
+714 -18

No files matched your search

+27 -13
View File
@@ -2976,7 +2976,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){ const _g=rowFor('group',group); meetReturn={kind:'group',id:group}; if(nativeCallOn()){ await callkitReportOutgoing(r.uuid, r.room, 'group', (_g&&_g.name)||'Group call', false); switchTab('meeting'); enterMeeting(r.room, false, { native:true, uuid:r.uuid }); return; } /* native: CallKit ring + native LiveKit + the REAL meeting window (mesh, no 2nd SFU) */ switchTab('meeting'); enterMeeting(r.room); } }
try{ const r=await postJSON('/api/groups/call/start',{ group }); if(r&&r.room){ const _g=rowFor('group',group); meetReturn={kind:'group',id:group}; if(nativeCallOn() && !_callWebViewMedia){ await callkitReportOutgoing(r.uuid, r.room, 'group', (_g&&_g.name)||'Group call', false); switchTab('meeting'); enterMeeting(r.room, false, { native:true, uuid:r.uuid }); return; } /* iOS native: CallKit ring + native LiveKit + the REAL meeting window (mesh, no 2nd SFU). Android (WebView media) + web fall through to the normal join; the callee still rings via the server FCM wake. */ 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>':''); }
@@ -2993,7 +2993,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}; if(nativeCallOn()){ await callkitReportOutgoing(r.uuid, r.room, 'dm', (_r&&_r.name)||'Call', false); switchTab('meeting'); enterMeeting(r.room, false, { native:true, uuid:r.uuid }); startRingback(); return; } /* native: CallKit ring + native LiveKit media + the REAL meeting window (mesh, no 2nd SFU) */ 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() && !_callWebViewMedia){ await callkitReportOutgoing(r.uuid, r.room, 'dm', (_r&&_r.name)||'Call', false); switchTab('meeting'); enterMeeting(r.room, false, { native:true, uuid:r.uuid }); startRingback(); return; } /* iOS native: CallKit ring + native LiveKit media + the REAL meeting window (mesh, no 2nd SFU). Android (WebView media) + web fall through to the normal join; the callee still rings via the server FCM wake. */ 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
@@ -4335,19 +4335,30 @@ 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 _callWebViewMedia=false; // Android Increment 1: native full-screen ring, but MEDIA stays in the WebView (iOS runs the call media natively via the plugin). Governs the answer/outgoing branches below.
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(){
const NC=nativeCallPlugin(); if(!NC) return;
// Server kill-switch: only take over calls with CallKit when the server enables it (callkit:true). When
// off, calls use the WebView flow (mic works). Flipped on only once native media carries the audio.
// Server kill-switch, per platform, so calls only go native when the server enables it (WebView flow otherwise).
const plat=nativePlatform();
let cfg={}; try{ cfg=await fetch('/api/meetings/config').then(r=>r.json()); }catch(_){}
if(!cfg || !cfg.callkit){ console.log('[callkit] disabled by server — using WebView calls'); 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(_){}
if(plat==='android'){
// Android Increment 1: native full-screen ring (over the lock screen, wakes from closed via FCM data), but
// the WebView still carries the call media. Gated on androidCall (server ANDROID_CALLS=1). The 'android' FCM
// token is already registered by setupNativePush — the server call-wake targets it, so no token step here.
if(!cfg || !cfg.androidCall){ console.log('[nativecall] Android native calling disabled by server — using WebView calls'); return; }
_callkitReady=true; _callWebViewMedia=true;
} else {
// iOS CallKit: native media (the plugin owns this identity's one LiveKit connection). Flipped on once native
// media carries the audio.
if(!cfg || !cfg.callkit){ console.log('[callkit] disabled by server — using WebView calls'); 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(_){}
}
// 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
@@ -4357,12 +4368,15 @@ async function setupNativeCall(){
_nativeAnswered.add(d.room);
dismissCallInvite(d.room);
// Open the REAL meeting window. Joining the mesh marks the call answered server-side, shows the caller's
// tile, and wires the end-both-sides lifecycle. The plugin already holds the LiveKit media, so this joins
// native (no 2nd SFU connection). meetReturn = the chat to land back on when the call ends.
// tile, and wires the end-both-sides lifecycle. meetReturn = the chat to land back on when the call ends.
const isGroup=(d.kind==='group');
meetReturn = isGroup ? { kind:'group', id:d.groupId } : { kind:'dm', id:d.callerId };
switchTab('meeting'); enterMeeting(d.room, false, { native:true, uuid:d.callUUID });
}catch(e){ console.warn('[callkit] answerCall failed:', e); } });
switchTab('meeting');
// Android (Increment 1): the WebView carries the media, so join the meeting NORMALLY. iOS: the plugin already
// holds this identity's LiveKit media, so join native (no 2nd SFU connection for the same identity).
if(_callWebViewMedia){ enterMeeting(d.room); }
else { enterMeeting(d.room, false, { native:true, uuid:d.callUUID }); }
}catch(e){ console.warn('[nativecall] answerCall failed:', e); } });
// Enforce the muted-by-default rule against the plugin's actual mic once the room connects (belt-and-braces
// for builds whose plugin still connects the mic live). meetMic is the source of truth for the mic button.
NC.addListener('callConnected', ()=>{ if(meetNative){ try{ NC.setMuted({ muted:!meetMic }); }catch(_){} if(meetCam){ try{ NC.setCamera({ on:true }); }catch(_){} } } }); // re-applies mic/cam after a #12 media reconnect too