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

+42 -3
View File
@@ -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 (_) {}
}
}
}