From b60ce6cda32dabefd89a0153b33c749eda2eae81 Mon Sep 17 00:00:00 2001 From: sravan Date: Fri, 25 Sep 2026 11:44:52 +0530 Subject: [PATCH] =?UTF-8?q?Android=20native=20calling=20=E2=80=94=20Increm?= =?UTF-8?q?ent=201:=20full-screen=20incoming=20ring=20+=20answer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../plugins/native-call/android/build.gradle | 65 ++++++ .../android/src/main/AndroidManifest.xml | 36 +++ .../nativecall/BzCallMessagingService.java | 57 +++++ .../bizgaze/connect/nativecall/CallStore.java | 109 ++++++++++ .../nativecall/IncomingCallActivity.java | 205 ++++++++++++++++++ .../connect/nativecall/NativeCallPlugin.java | 131 +++++++++++ mobile/plugins/native-call/package.json | 5 + mobile/scripts/android-patch.js | 27 +++ server/config.js | 8 + server/public/home.html | 40 ++-- server/push.js | 45 +++- server/routes.js | 4 +- 12 files changed, 714 insertions(+), 18 deletions(-) create mode 100644 mobile/plugins/native-call/android/build.gradle create mode 100644 mobile/plugins/native-call/android/src/main/AndroidManifest.xml create mode 100644 mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/BzCallMessagingService.java create mode 100644 mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/CallStore.java create mode 100644 mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/IncomingCallActivity.java create mode 100644 mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/NativeCallPlugin.java 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.