diff --git a/mobile/plugins/native-call/android/build.gradle b/mobile/plugins/native-call/android/build.gradle
new file mode 100644
index 0000000..0a6b206
--- /dev/null
+++ b/mobile/plugins/native-call/android/build.gradle
@@ -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"
+}
diff --git a/mobile/plugins/native-call/android/src/main/AndroidManifest.xml b/mobile/plugins/native-call/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..b766b27
--- /dev/null
+++ b/mobile/plugins/native-call/android/src/main/AndroidManifest.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/BzCallMessagingService.java b/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/BzCallMessagingService.java
new file mode 100644
index 0000000..ca617a2
--- /dev/null
+++ b/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/BzCallMessagingService.java
@@ -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 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 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; }
+}
diff --git a/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/CallStore.java b/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/CallStore.java
new file mode 100644
index 0000000..6b5fb1f
--- /dev/null
+++ b/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/CallStore.java
@@ -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 active = ConcurrentHashMap.newKeySet();
+ private static final java.util.Set 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 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 e : call.entrySet()) i.putExtra(e.getKey(), e.getValue());
+ return i;
+ }
+
+ private static PendingIntent activityPI(Context ctx, String action, int req, Map 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 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) {}
+ }
+}
diff --git a/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/IncomingCallActivity.java b/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/IncomingCallActivity.java
new file mode 100644
index 0000000..3eb8ea1
--- /dev/null
+++ b/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/IncomingCallActivity.java
@@ -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();
+ });
+ }
+}
diff --git a/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/NativeCallPlugin.java b/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/NativeCallPlugin.java
new file mode 100644
index 0000000..3f61983
--- /dev/null
+++ b/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/NativeCallPlugin.java
@@ -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 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(); }
+}
diff --git a/mobile/plugins/native-call/package.json b/mobile/plugins/native-call/package.json
index 835a9b2..1362eaa 100644
--- a/mobile/plugins/native-call/package.json
+++ b/mobile/plugins/native-call/package.json
@@ -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": {
diff --git a/mobile/scripts/android-patch.js b/mobile/scripts/android-patch.js
index 4325a61..0a15201 100644
--- a/mobile/scripts/android-patch.js
+++ b/mobile/scripts/android-patch.js
@@ -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 . Keyed on bzcCallSvcRemove so re-runs never duplicate it.
+ if (!s.includes('bzcCallSvcRemove')) {
+ // Ensure xmlns:tools is declared on (tools:node="remove" needs it).
+ if (!/]*xmlns:tools=/.test(s)) {
+ s = s.replace(/',
+ ' ',
+ ' ',
+ ].join('\n');
+ const before = s;
+ s = s.replace(/<\/application>/, removal + '');
+ if (s !== before) console.log(' Capacitor MessagingService removal injected (native-call takes over MESSAGING_EVENT)');
+ else console.log(' could not find — 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);
}
diff --git a/server/config.js b/server/config.js
index 30f1d76..4540002 100644
--- a/server/config.js
+++ b/server/config.js
@@ -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,
diff --git a/server/public/home.html b/server/public/home.html
index eeb631f..5236ea1 100644
--- a/server/public/home.html
+++ b/server/public/home.html
@@ -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?'Join':''); }
@@ -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
diff --git a/server/push.js b/server/push.js
index 9cf0e3e..49774e9 100644
--- a/server/push.js
+++ b/server/push.js
@@ -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 (_) {}
+ }
}
}
diff --git a/server/routes.js b/server/routes.js
index fae99c3..22601bb 100644
--- a/server/routes.js
+++ b/server/routes.js
@@ -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.