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
+707 -11

No files matched your search

@@ -0,0 +1,65 @@
// Android build for the native-call plugin. Mirrors the official @capacitor/* plugin module layout (see
// node_modules/@capacitor/status-bar/android/build.gradle) so it drops into the CI-generated app exactly like
// the plugins that already build. Java (not Kotlin) to match the proven official-plugin structure — no extra
// kotlin-gradle-plugin wiring in the library module, one less thing to fail when authored blind.
ext {
junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
androidxCoreVersion = project.hasProperty('androidxCoreVersion') ? rootProject.ext.androidxCoreVersion : '1.12.0'
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.6.1'
// Same FCM version the @capacitor/push-notifications plugin pulls in, so we don't fork the Firebase version.
firebaseMessagingVersion = project.hasProperty('firebaseMessagingVersion') ? rootProject.ext.firebaseMessagingVersion : '23.3.1'
}
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.2.1'
}
}
apply plugin: 'com.android.library'
android {
namespace "com.bizgaze.connect.nativecall"
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34
defaultConfig {
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34
versionCode 1
versionName "1.0"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
lintOptions {
abortOnError false
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
repositories {
google()
mavenCentral()
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':capacitor-android')
// The Biz Connect call-wake FirebaseMessagingService extends the @capacitor/push-notifications one so a
// single service handles BOTH regular push (forwarded via super) and call-wake data messages.
implementation project(':capacitor-push-notifications')
implementation "androidx.core:core:$androidxCoreVersion"
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
// RemoteMessage / FirebaseMessaging (transitive from the push plugin, but declared so this module compiles
// against it directly; same version → no resolution conflict).
implementation "com.google.firebase:firebase-messaging:$firebaseMessagingVersion"
}
@@ -0,0 +1,36 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Full-screen incoming-call UI over the lock screen (Android's answer to CallKit). VIBRATE + WAKE_LOCK
for the ring; POST_NOTIFICATIONS is also added app-side by android-patch.js. -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application>
<!-- Our FirebaseMessagingService REPLACES the @capacitor/push-notifications one: it handles call-wake
DATA messages natively (to ring when the app is closed) and forwards everything else to Capacitor's
push handling via super.onMessageReceived(). Capacitor's service is removed in the APP manifest
(android-patch.js, highest merge priority) so there is exactly one MESSAGING_EVENT receiver; the high
intent-filter priority here is belt-and-braces so ours wins even if that removal ever no-ops. -->
<service
android:name="com.bizgaze.connect.nativecall.BzCallMessagingService"
android:exported="false">
<intent-filter android:priority="999">
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<!-- The full-screen incoming-call screen. showWhenLocked + turnScreenOn make it appear over the lock
screen and wake the display; excludeFromRecents keeps it out of the recents list. -->
<activity
android:name="com.bizgaze.connect.nativecall.IncomingCallActivity"
android:exported="false"
android:launchMode="singleTop"
android:excludeFromRecents="true"
android:showOnLockScreen="true"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:theme="@android:style/Theme.DeviceDefault.NoActionBar" />
</application>
</manifest>
@@ -0,0 +1,57 @@
package com.bizgaze.connect.nativecall;
import androidx.annotation.NonNull;
import com.google.firebase.messaging.RemoteMessage;
import java.util.HashMap;
import java.util.Map;
// The single FirebaseMessagingService for the app. It REPLACES @capacitor/push-notifications' MessagingService
// (removed in AndroidManifest via tools:node="remove") so there is exactly one MESSAGING_EVENT receiver:
// * call-wake DATA messages (data.bzcall == "1") → ring natively, even when the app is closed. A high-priority
// data-only FCM message is delivered to onMessageReceived in any app state (a "notification" message would
// just sit in the tray and never run our code).
// * everything else → super.onMessageReceived(), i.e. Capacitor's normal push handling (chat/meeting banners).
// onNewToken is likewise forwarded so the web still registers this device's "android" FCM token — the same token
// the server targets for call-wakes.
public class BzCallMessagingService extends com.capacitorjs.plugins.pushnotifications.MessagingService {
@Override
public void onMessageReceived(@NonNull RemoteMessage remoteMessage) {
Map<String, String> data = remoteMessage.getData();
if (data != null && "1".equals(data.get("bzcall"))) {
String type = data.get("type");
String uuid = data.get("callUUID");
if ("cancel".equals(type)) {
CallStore.markEnded(uuid);
CallStore.cancel(getApplicationContext());
IncomingCallActivity.dismissIfShowing();
return;
}
Map<String, String> call = new HashMap<>();
call.put("callUUID", uuid == null ? "" : uuid);
call.put("room", or(data.get("room")));
call.put("kind", data.get("kind") == null ? "dm" : data.get("kind"));
call.put("callerId", or(data.get("callerId")));
call.put("callerName", data.get("callerName") != null ? data.get("callerName")
: (data.get("groupName") != null ? data.get("groupName") : "Incoming call"));
call.put("groupId", or(data.get("groupId")));
call.put("groupName", or(data.get("groupName")));
// Server sends hasVideo as "1"/""; normalize to "true"/"false" so the UI's checks are consistent
// with the WebSocket path (reportIncomingCall stores Boolean.toString()).
String hv = data.get("hasVideo");
call.put("hasVideo", ("1".equals(hv) || "true".equals(hv)) ? "true" : "false");
CallStore.showIncoming(getApplicationContext(), call);
return;
}
super.onMessageReceived(remoteMessage);
}
@Override
public void onNewToken(@NonNull String s) {
super.onNewToken(s);
}
private static String or(String v) { return v == null ? "" : v; }
}
@@ -0,0 +1,109 @@
package com.bizgaze.connect.nativecall;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.media.AudioAttributes;
import android.media.RingtoneManager;
import android.net.Uri;
import android.os.Build;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
// Builds and shows the full-screen incoming-call notification (Android's stand-in for CallKit's system ring),
// and dedupes calls so the FCM push path and the WebSocket path don't ring the same call twice. One call at a
// time is assumed (a single NOTIF_ID), matching the app's 1:1 / single-group-call model.
class CallStore {
static final String CHANNEL_ID = "bzc_incoming_calls";
static final int NOTIF_ID = 2748;
static final String ACTION_RING = "com.bizgaze.connect.nativecall.RING";
static final String ACTION_ACCEPT = "com.bizgaze.connect.nativecall.ACCEPT";
static final String ACTION_DECLINE = "com.bizgaze.connect.nativecall.DECLINE";
// callUUIDs currently ringing/active and ones we've already ended — so a late/duplicate wake doesn't re-ring.
private static final java.util.Set<String> active = ConcurrentHashMap.newKeySet();
private static final java.util.Set<String> ended = ConcurrentHashMap.newKeySet();
static boolean isKnown(String uuid) { return uuid != null && (active.contains(uuid) || ended.contains(uuid)); }
static void markActive(String uuid) { if (uuid != null) active.add(uuid); }
static void markEnded(String uuid) { if (uuid != null) { active.remove(uuid); ended.add(uuid); } }
private static void ensureChannel(Context ctx) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
if (nm == null || nm.getNotificationChannel(CHANNEL_ID) != null) return;
NotificationChannel ch = new NotificationChannel(CHANNEL_ID, "Incoming calls", NotificationManager.IMPORTANCE_HIGH);
ch.setDescription("Ringing for incoming Biz Connect calls");
Uri ring = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE);
AudioAttributes attrs = new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build();
if (ring != null) ch.setSound(ring, attrs);
ch.enableVibration(true);
ch.setVibrationPattern(new long[]{0, 700, 700, 700, 700});
ch.setLockscreenVisibility(NotificationCompat.VISIBILITY_PUBLIC);
nm.createNotificationChannel(ch);
}
private static Intent activityIntent(Context ctx, String action, Map<String, String> call) {
Intent i = new Intent(ctx, IncomingCallActivity.class);
i.setAction(action);
i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
for (Map.Entry<String, String> e : call.entrySet()) i.putExtra(e.getKey(), e.getValue());
return i;
}
private static PendingIntent activityPI(Context ctx, String action, int req, Map<String, String> call) {
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) flags |= PendingIntent.FLAG_IMMUTABLE;
return PendingIntent.getActivity(ctx, req, activityIntent(ctx, action, call), flags);
}
// Show the ringing notification with a full-screen intent (launches IncomingCallActivity over the lock
// screen when allowed; otherwise shows as a heads-up with Accept/Decline buttons).
static void showIncoming(Context ctx, Map<String, String> call) {
String uuid = call.get("callUUID");
if (uuid == null || ended.contains(uuid)) return;
active.add(uuid);
ensureChannel(ctx);
String title = call.get("callerName");
if (title == null || title.isEmpty()) title = "Incoming call";
boolean video = "true".equals(call.get("hasVideo"));
PendingIntent full = activityPI(ctx, ACTION_RING, 1, call);
PendingIntent accept = activityPI(ctx, ACTION_ACCEPT, 2, call);
PendingIntent decline = activityPI(ctx, ACTION_DECLINE, 3, call);
NotificationCompat.Builder b = new NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(android.R.drawable.sym_call_incoming)
.setContentTitle(title)
.setContentText(video ? "Incoming video call" : "Incoming call")
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(true)
.setAutoCancel(false)
.setContentIntent(full)
.setFullScreenIntent(full, true)
.addAction(0, "Decline", decline)
.addAction(0, "Accept", accept);
try {
NotificationManagerCompat.from(ctx).notify(NOTIF_ID, b.build());
} catch (SecurityException ignored) { /* POST_NOTIFICATIONS not granted */ }
}
static void cancel(Context ctx) {
try { NotificationManagerCompat.from(ctx).cancel(NOTIF_ID); } catch (Exception ignored) {}
}
}
@@ -0,0 +1,205 @@
package com.bizgaze.connect.nativecall;
import android.app.Activity;
import android.app.KeyguardManager;
import android.content.Context;
import android.content.Intent;
import android.graphics.Color;
import android.media.Ringtone;
import android.media.RingtoneManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.view.Gravity;
import android.view.View;
import android.view.WindowManager;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.TextView;
import com.getcapacitor.JSObject;
// The full-screen incoming-call UI (Android's stand-in for the CallKit ring screen). Launched over the lock
// screen by CallStore's full-screen-intent notification. Shows the caller + Accept/Decline; wires the choice
// back to the WebView via NativeCallPlugin. Media still runs in the WebView (Increment 1) — Accept just brings
// the app to the front and fires "answerCall", which the web joins as a normal meeting.
public class IncomingCallActivity extends Activity {
private static IncomingCallActivity current;
private Ringtone ringtone;
private JSObject callData;
private boolean handled = false;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
showOverLockScreen();
handleIntent(getIntent());
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
handleIntent(intent);
}
private void handleIntent(Intent intent) {
String action = intent != null ? intent.getAction() : null;
callData = extras(intent);
if (CallStore.ACTION_ACCEPT.equals(action)) { doAccept(); return; }
if (CallStore.ACTION_DECLINE.equals(action)) { doDecline(); return; }
// RING (default): show the UI + ring.
current = this;
setContentView(buildUi());
startRinging();
}
private JSObject extras(Intent intent) {
JSObject o = new JSObject();
String[] keys = {"callUUID", "room", "kind", "callerId", "callerName", "groupId", "groupName", "hasVideo"};
if (intent != null) for (String k : keys) { String v = intent.getStringExtra(k); if (v != null) o.put(k, v); }
return o;
}
private void showOverLockScreen() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true);
setTurnScreenOn(true);
KeyguardManager km = (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE);
if (km != null) km.requestDismissKeyguard(this, null);
} else {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
| WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
| WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD);
}
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
private View buildUi() {
LinearLayout root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
root.setGravity(Gravity.CENTER);
root.setBackgroundColor(Color.parseColor("#16294F")); // brand blue
int pad = dp(24);
root.setPadding(pad, pad, pad, pad);
boolean video = "true".equals(callData.getString("hasVideo", "false"));
String name = callData.getString("callerName", "Incoming call");
if (name == null || name.isEmpty()) name = "Incoming call";
TextView label = new TextView(this);
label.setText(video ? "Incoming video call" : "Incoming call");
label.setTextColor(Color.parseColor("#BBD0FF"));
label.setTextSize(16);
label.setGravity(Gravity.CENTER);
TextView who = new TextView(this);
who.setText(name);
who.setTextColor(Color.WHITE);
who.setTextSize(30);
who.setGravity(Gravity.CENTER);
LinearLayout.LayoutParams whoLp = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
whoLp.topMargin = dp(12);
whoLp.bottomMargin = dp(48);
LinearLayout buttons = new LinearLayout(this);
buttons.setOrientation(LinearLayout.HORIZONTAL);
buttons.setGravity(Gravity.CENTER);
Button decline = actionButton("Decline", "#DC2626");
decline.setOnClickListener(v -> doDecline());
Button accept = actionButton("Accept", "#16A34A");
accept.setOnClickListener(v -> doAccept());
LinearLayout.LayoutParams bl = new LinearLayout.LayoutParams(dp(120), dp(56));
bl.rightMargin = dp(20);
LinearLayout.LayoutParams br = new LinearLayout.LayoutParams(dp(120), dp(56));
buttons.addView(decline, bl);
buttons.addView(accept, br);
root.addView(label);
root.addView(who, whoLp);
root.addView(buttons);
return root;
}
private Button actionButton(String text, String colorHex) {
Button b = new Button(this);
b.setText(text);
b.setTextColor(Color.WHITE);
b.setAllCaps(false);
b.setBackgroundColor(Color.parseColor(colorHex));
return b;
}
private void startRinging() {
try {
Uri uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE);
if (uri == null) return;
ringtone = RingtoneManager.getRingtone(getApplicationContext(), uri);
if (ringtone != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) ringtone.setLooping(true);
ringtone.play();
}
} catch (Exception ignored) {}
}
private void stopRinging() {
try { if (ringtone != null && ringtone.isPlaying()) ringtone.stop(); } catch (Exception ignored) {}
ringtone = null;
}
private void doAccept() {
if (handled) { finish(); return; }
handled = true;
stopRinging();
String uuid = callData.getString("callUUID", "");
CallStore.markActive(uuid);
CallStore.cancel(getApplicationContext());
// Bring the app to the front so the WebView loads and joins the meeting, then fire the answer (retained
// in the plugin until the WebView JS attaches — covers a cold/locked pickup).
Intent launch = getPackageManager().getLaunchIntentForPackage(getPackageName());
if (launch != null) {
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
startActivity(launch);
}
NativeCallPlugin.deliverAnswer(callData);
finishAndClear();
}
private void doDecline() {
if (handled) { finish(); return; }
handled = true;
stopRinging();
CallStore.markEnded(callData.getString("callUUID", ""));
CallStore.cancel(getApplicationContext());
NativeCallPlugin.deliverEnd(callData);
finishAndClear();
}
private void finishAndClear() {
if (current == this) current = null;
finish();
}
@Override
protected void onDestroy() {
stopRinging();
if (current == this) current = null;
super.onDestroy();
}
private int dp(int v) { return Math.round(getResources().getDisplayMetrics().density * v); }
// Called when the call ends elsewhere (remote hang-up / another device answered) so the ring screen goes away.
static void dismissIfShowing() {
final IncomingCallActivity a = current;
if (a != null) a.runOnUiThread(() -> {
a.stopRinging();
if (!a.isFinishing()) a.finish();
});
}
}
@@ -0,0 +1,131 @@
package com.bizgaze.connect.nativecall;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import com.google.firebase.messaging.FirebaseMessaging;
// Native calling for Biz Connect (Android). Registered by `cap sync` as window.Capacitor.Plugins.NativeCall —
// the SAME jsName the iOS plugin uses, so the web bridge (setupNativeCall in home.html) drives both platforms
// through one code path.
//
// INCREMENT 1 (ring + answer): rings incoming calls with a full-screen-over-the-lock-screen UI (Android's
// answer to CallKit), woken by a high-priority FCM DATA message even when the app is closed
// (BzCallMessagingService). Answer/decline are bridged back to the WebView, which joins the meeting the normal
// way — MEDIA STILL RUNS IN THE WEBVIEW for now (works in the foreground). Background call audio via a native
// LiveKit connection + a foreground service is Increment 2, mirroring the iOS build history.
//
// The video / screen-share / hole-punch / transcription methods the iOS plugin implements are declared here as
// safe no-ops so the shared web bridge never rejects when it calls them on Android; they get real bodies in the
// later increments.
@CapacitorPlugin(name = "NativeCall")
public class NativeCallPlugin extends Plugin {
// The live plugin instance + a buffered answer for the cold-start case (a call answered from a killed app:
// the answer arrives before the WebView/JS has attached a listener). notifyListeners(..., retain=true) also
// buffers, but when the plugin itself isn't loaded yet we hold the event here and flush it in load().
private static NativeCallPlugin instance;
private static JSObject pendingAnswer;
@Override
public void load() {
instance = this;
if (pendingAnswer != null) {
JSObject a = pendingAnswer;
pendingAnswer = null;
notifyListeners("answerCall", a, true);
}
}
@Override
protected void handleOnDestroy() {
super.handleOnDestroy();
if (instance == this) instance = null;
}
// Called from IncomingCallActivity when the user accepts. Fires the shared "answerCall" event the web
// listens for; retained so a killed/locked pickup isn't lost before the WebView JS attaches.
static void deliverAnswer(JSObject data) {
if (instance != null) instance.notifyListeners("answerCall", data, true);
else pendingAnswer = data;
}
// Called on decline / remote hang-up. The web ends/declines the call server-side.
static void deliverEnd(JSObject data) {
if (instance != null) instance.notifyListeners("endCall", data);
}
// MARK: - JS-callable methods
// The FCM registration token — the same token @capacitor/push-notifications registers as an "android"
// device, which is exactly what the server call-wake targets. Returned so the web can confirm/re-register.
@PluginMethod
public void getToken(final PluginCall call) {
FirebaseMessaging.getInstance().getToken().addOnCompleteListener(task -> {
JSObject ret = new JSObject();
ret.put("token", task.isSuccessful() && task.getResult() != null ? task.getResult() : "");
call.resolve(ret);
});
}
// Ring CallKit-style from a WebSocket call event (a 2nd path alongside the FCM push, for when the app is
// open). Deduped by callUUID in CallStore, so double-firing with the push is harmless.
@PluginMethod
public void reportIncomingCall(PluginCall call) {
String uuid = call.getString("callUUID", "");
if (uuid.isEmpty()) { call.reject("callUUID required"); return; }
if (CallStore.isKnown(uuid)) { call.resolve(); return; }
java.util.Map<String, String> c = new java.util.HashMap<>();
c.put("callUUID", uuid);
c.put("room", call.getString("room", ""));
c.put("kind", call.getString("kind", "dm"));
c.put("callerId", call.getString("callerId", ""));
c.put("callerName", call.getString("callerName", call.getString("groupName", "Incoming call")));
c.put("groupId", call.getString("groupId", ""));
c.put("groupName", call.getString("groupName", ""));
c.put("hasVideo", Boolean.toString(Boolean.TRUE.equals(call.getBoolean("hasVideo", false))));
CallStore.showIncoming(getContext(), c);
call.resolve();
}
// Outgoing call from the web app. Increment 1: media stays in the WebView, so there is nothing to ring or
// connect natively here — the callee is rung by the server's FCM call-wake. Just record it so a later
// endCall/cancel can match. (Increment 2 will start a foreground service + native LiveKit here.)
@PluginMethod
public void reportOutgoingCall(PluginCall call) {
String uuid = call.getString("callUUID", "");
if (!uuid.isEmpty()) CallStore.markActive(uuid);
call.resolve();
}
// Stop ringing / clear the incoming UI for a call (remote hung up, we ended, or another device answered).
@PluginMethod
public void endCall(PluginCall call) {
String uuid = call.getString("callUUID", "");
if (!uuid.isEmpty()) CallStore.markEnded(uuid);
CallStore.cancel(getContext());
IncomingCallActivity.dismissIfShowing();
call.resolve();
}
// Increment 1: mute is handled by the WebView (it owns the media). No-op so the shared bridge doesn't reject.
@PluginMethod public void setMuted(PluginCall call) { call.resolve(); }
// --- Increment 2+ methods (video / screen-share / hole-punch / #12 reconnect / #5 transcript). Safe no-ops
// for now so the shared web bridge never rejects on Android; real bodies land with the native-media build. ---
@PluginMethod public void reconnectRoom(PluginCall call) { call.resolve(); }
@PluginMethod public void setCamera(PluginCall call) { call.resolve(); }
@PluginMethod public void switchCamera(PluginCall call) { call.resolve(); }
@PluginMethod public void startScreenShare(PluginCall call) { call.resolve(); }
@PluginMethod public void stopScreenShare(PluginCall call) { call.resolve(); }
@PluginMethod public void startMeetingScreenShare(PluginCall call) { call.resolve(); }
@PluginMethod public void stopMeetingScreenShare(PluginCall call) { call.resolve(); }
@PluginMethod public void setHolePunch(PluginCall call) { call.resolve(); }
@PluginMethod public void setTileZoom(PluginCall call) { call.resolve(); }
@PluginMethod public void syncVideoTiles(PluginCall call) { call.resolve(); }
@PluginMethod public void startTranscription(PluginCall call) { call.resolve(); }
@PluginMethod public void stopTranscription(PluginCall call) { call.resolve(); }
@PluginMethod public void feedAudio(PluginCall call) { call.resolve(); }
}
+5
View File
@@ -10,12 +10,17 @@
"files": [
"dist/",
"ios/",
"android/src/main/",
"android/build.gradle",
"NativeCall.podspec",
"Package.swift"
],
"capacitor": {
"ios": {
"src": "ios"
},
"android": {
"src": "android"
}
},
"devDependencies": {
+27
View File
@@ -15,6 +15,33 @@ const p = process.argv[2];
if (!p || !fs.existsSync(p)) { console.log(' (AndroidManifest.xml not found — permission patch skipped)'); process.exit(0); }
try {
let s = fs.readFileSync(p, 'utf8');
// (1) Native-call FirebaseMessagingService swap. The native-call plugin declares its own MESSAGING_EVENT
// service (BzCallMessagingService) that handles call-wake data messages AND forwards regular push to Capacitor
// via super. Remove @capacitor/push-notifications' own MessagingService here — the APP manifest is the highest
// merge priority, so this reliably guarantees exactly one receiver. Needs the `tools` namespace + an insert
// inside <application>. Keyed on bzcCallSvcRemove so re-runs never duplicate it.
if (!s.includes('bzcCallSvcRemove')) {
// Ensure xmlns:tools is declared on <manifest> (tools:node="remove" needs it).
if (!/<manifest[^>]*xmlns:tools=/.test(s)) {
s = s.replace(/<manifest(\s)/, '<manifest xmlns:tools="http://schemas.android.com/tools"$1');
}
const removal = [
'',
' <!-- bzcCallSvcRemove: the native-call plugin\'s BzCallMessagingService replaces this one (it forwards regular push via super) -->',
' <service android:name="com.capacitorjs.plugins.pushnotifications.MessagingService" tools:node="remove" />',
' ',
].join('\n');
const before = s;
s = s.replace(/<\/application>/, removal + '</application>');
if (s !== before) console.log(' Capacitor MessagingService removal injected (native-call takes over MESSAGING_EVENT)');
else console.log(' could not find </application> — MessagingService removal skipped');
fs.writeFileSync(p, s);
} else {
console.log(' Capacitor MessagingService removal already present');
}
// (2) WebView permissions.
if (s.includes('bzcAndroidPerms') || s.includes('android.permission.RECORD_AUDIO')) {
console.log(' Android permissions already present'); process.exit(0);
}
+8
View File
@@ -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,
+22 -8
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(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
+40 -1
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,13 +201,22 @@ 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 = []; }
// 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 (_) {}
}
}
}
// ---------------- public API ----------------
const nativeReady = !!(fcmSA || apnsCfg);
+2 -2
View File
@@ -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.