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:
1 parent
a8e0f549d4
commit
b60ce6cda3
12 files changed
+714
-18
No files matched your search
@@ -48,6 +48,13 @@ const GIPHY_API_KEY = process.env.GIPHY_API_KEY || '';
|
||||
// WebView's WebRTC can't capture it (mic dead). Set CALLKIT_ENABLED=1 in the server .env to enable.
|
||||
const CALLKIT_ENABLED = process.env.CALLKIT_ENABLED === '1';
|
||||
|
||||
// Native calling on ANDROID (ConnectionService-style full-screen ring via a high-priority FCM data message +
|
||||
// the native-call plugin). Same config-gated pattern as CALLKIT_ENABLED, but INDEPENDENT so Android can be
|
||||
// switched on without touching iOS. OFF (default) → Android calls use the WebView flow (works today, foreground
|
||||
// only); ON → incoming calls ring natively (full-screen, over the lock screen, when the app is closed). Requires
|
||||
// FCM configured (FCM_SERVICE_ACCOUNT) + an app build carrying the native-call Android plugin. Set ANDROID_CALLS=1.
|
||||
const ANDROID_CALLS = process.env.ANDROID_CALLS === '1';
|
||||
|
||||
module.exports = {
|
||||
PORT: process.env.PORT || 8090,
|
||||
HTTPS_PORT: process.env.HTTPS_PORT || 8443,
|
||||
@@ -65,6 +72,7 @@ module.exports = {
|
||||
PUBLIC_BASE_URL,
|
||||
GIPHY_API_KEY,
|
||||
CALLKIT_ENABLED,
|
||||
ANDROID_CALLS,
|
||||
PUBLIC_DIR,
|
||||
REC_DIR,
|
||||
TRANS_DIR,
|
||||
|
||||
+27
-13
@@ -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
|
||||
|
||||
+42
-3
@@ -69,6 +69,19 @@ async function sendFcm(token, payload) {
|
||||
return { ok: res.ok, dead: res.status === 404 || res.status === 410 }; // UNREGISTERED tokens
|
||||
}
|
||||
|
||||
// A high-priority, DATA-ONLY FCM message — no `notification` block, so Android delivers it to the app's
|
||||
// FirebaseMessagingService.onMessageReceived() in ANY app state (foreground/background/killed) instead of just
|
||||
// parking it in the tray. That's what lets the native-call plugin ring an incoming call when the app is closed
|
||||
// (the Android equivalent of an iOS VoIP push). `android.priority:"high"` bypasses Doze batching.
|
||||
async function sendFcmData(token, data) {
|
||||
const at = await fcmAccessToken();
|
||||
const msg = { message: { token, data: strData(data || {}), android: { priority: 'high' } } };
|
||||
const res = await fetch('https://fcm.googleapis.com/v1/projects/' + fcmSA.project_id + '/messages:send', {
|
||||
method: 'POST', headers: { Authorization: 'Bearer ' + at, 'Content-Type': 'application/json' }, body: JSON.stringify(msg),
|
||||
});
|
||||
return { ok: res.ok, dead: res.status === 404 || res.status === 410 };
|
||||
}
|
||||
|
||||
// ---------------- APNs (iOS), token-based over HTTP/2 ----------------
|
||||
let apnsKey = null, apnsCfg = null;
|
||||
(function loadApns() {
|
||||
@@ -160,6 +173,23 @@ async function sendCallNotification(userId, data) {
|
||||
}
|
||||
return 'voip';
|
||||
}
|
||||
// Android native ring: send a high-priority DATA message to the user's 'android' tokens so the native-call
|
||||
// plugin rings even when the app is closed (BzCallMessagingService → full-screen incoming UI). Gated on
|
||||
// ANDROID_CALLS so the app can ship with the plugin dormant and be switched on server-side, like CALLKIT_ENABLED
|
||||
// for iOS. Returns early (no banner) so the same token isn't ALSO buzzed by the fallback push below.
|
||||
const android = toks.filter((t) => t.platform === 'android');
|
||||
if (android.length && fcmSA && process.env.ANDROID_CALLS === '1') {
|
||||
const cd = {
|
||||
bzcall: '1', type: 'invite',
|
||||
callUUID: data.callUUID || '', room: data.room || '', kind: data.kind || 'dm',
|
||||
callerId: data.callerId || '', callerName: data.callerName || 'Incoming call',
|
||||
groupId: data.groupId || '', groupName: data.groupName || '', hasVideo: data.hasVideo ? '1' : '',
|
||||
};
|
||||
for (const t of android) {
|
||||
try { const r = await sendFcmData(t.token, cd); if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) {}
|
||||
}
|
||||
return 'fcm-call';
|
||||
}
|
||||
await sendToUser(userId, {
|
||||
title: data.title, body: data.body, kind: data.kind, id: data.callerId || data.groupId,
|
||||
room: data.room, tag: 'call:' + data.room, data: { kind: data.kind, id: data.callerId || data.groupId, room: data.room, call: 1 },
|
||||
@@ -171,11 +201,20 @@ async function sendCallNotification(userId, data) {
|
||||
// VoIP push so it reaches a killed app that has no WebSocket — the CallKit plugin ends the reported call.
|
||||
// No-op for non-VoIP devices (their ring is a normal notification that just goes away).
|
||||
async function sendCallCancel(userId, callUUID) {
|
||||
if (!apnsCfg || !callUUID || process.env.CALLKIT_ENABLED !== '1') return;
|
||||
if (!callUUID) return;
|
||||
let toks = [];
|
||||
try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
|
||||
for (const t of toks.filter((t) => t.platform === 'ios-voip')) {
|
||||
try { const r = await sendApnsVoip(t.token, { type: 'cancel', callUUID }); if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) {}
|
||||
// iOS: cancel the CallKit ring via a VoIP push.
|
||||
if (apnsCfg && process.env.CALLKIT_ENABLED === '1') {
|
||||
for (const t of toks.filter((t) => t.platform === 'ios-voip')) {
|
||||
try { const r = await sendApnsVoip(t.token, { type: 'cancel', callUUID }); if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) {}
|
||||
}
|
||||
}
|
||||
// Android: cancel the native ring via a high-priority data message (BzCallMessagingService dismisses it).
|
||||
if (fcmSA && process.env.ANDROID_CALLS === '1') {
|
||||
for (const t of toks.filter((t) => t.platform === 'android')) {
|
||||
try { const r = await sendFcmData(t.token, { bzcall: '1', type: 'cancel', callUUID }); if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -116,7 +116,7 @@ const API_KEY_SCOPES = ['report:read', 'audit:read'];
|
||||
const { onlineAgents, meetingRooms, groupCalls, dmCalls } = require('./presence');
|
||||
const CALLS = require('./calls');
|
||||
require('./reminders'); // start the 10-minute meeting-reminder loop
|
||||
const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL, LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED, PUBLIC_BASE_URL, GIPHY_API_KEY, CALLKIT_ENABLED } = require('./config');
|
||||
const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL, LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED, PUBLIC_BASE_URL, GIPHY_API_KEY, CALLKIT_ENABLED, ANDROID_CALLS } = require('./config');
|
||||
const https = require('https');
|
||||
// Small GET-JSON helper for the GIPHY proxy (keeps the key server-side).
|
||||
function fetchJSON(url) {
|
||||
@@ -993,7 +993,7 @@ route('POST', '/api/calls/invite', async (req, res) => {
|
||||
// Does this deployment use the LiveKit SFU for meeting media? The client asks on load; if sfu is
|
||||
// false it uses the built-in P2P mesh. url is the browser-facing signaling endpoint (wss://…).
|
||||
route('GET', '/api/meetings/config', (req, res) => {
|
||||
json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '', callkit: CALLKIT_ENABLED, fcm: PUSH.fcmReady() });
|
||||
json(res, 200, { sfu: LIVEKIT_ENABLED, url: LIVEKIT_ENABLED ? LIVEKIT_URL : '', callkit: CALLKIT_ENABLED, androidCall: ANDROID_CALLS, fcm: PUSH.fcmReady() });
|
||||
});
|
||||
|
||||
// #5 GIF search — server-side GIPHY proxy so the API key never reaches the browser. Empty q → trending.
|
||||
|
||||
Reference in new issue
Block a user