Android native calling — Increment 1: full-screen incoming ring + answer
First slice of Android Phase C (full LiveKit parity), mirroring how iOS started: native full-screen incoming call UI that wakes the app from closed, with call MEDIA still in the WebView (foreground). Native LiveKit media + a foreground service for true background audio are Increment 2. Native (new Android side of the native-call plugin, Java to match the proven official-plugin module layout): - NativeCallPlugin: registered as window.Capacitor.Plugins.NativeCall (same jsName as iOS, so the shared web bridge drives both). getToken (FCM token), reportIncomingCall (ring), reportOutgoingCall, endCall, setMuted; video/screen/hole-punch/transcript methods are safe no-ops until later increments so the shared bridge never rejects. - BzCallMessagingService: extends @capacitor/push-notifications' MessagingService — handles high-priority call-wake DATA messages (data.bzcall==1) to ring when closed, forwards all other push via super. - IncomingCallActivity: full-screen call screen over the lock screen (showWhenLocked/turnScreenOn), Accept/Decline, looping ringtone. - CallStore: incoming-call notification (channel, full-screen-intent, actions) + UUID dedup so the FCM and WebSocket paths don't double-ring. - Plugin manifest: BzCallMessagingService + IncomingCallActivity + call perms (USE_FULL_SCREEN_INTENT/VIBRATE/WAKE_LOCK). - android-patch.js: removes Capacitor's MessagingService in the APP manifest (highest merge priority) so ours is the sole MESSAGING_EVENT receiver; ensures the tools namespace. Idempotent. - package.json: capacitor.android.src wired. Server (dormant until ANDROID_CALLS=1): - push.js sendFcmData: high-priority data-only FCM (rings when closed). sendCallNotification rings 'android' tokens via it; sendCallCancel too. - config.js ANDROID_CALLS flag; /api/meetings/config exposes androidCall. Web (home.html) — platform-aware, iOS/web behavior byte-for-byte unchanged: - setupNativeCall: Android gated on cfg.androidCall, sets _callWebViewMedia (native ring, WebView media); iOS keeps CallKit native-media path. - answerCall + both outgoing sites branch on _callWebViewMedia so Android joins the meeting normally while the callee still rings via the server. Safe to deploy now: dormant until ANDROID_CALLS=1 AND an APK carrying the plugin is installed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
a8e0f549d4
commit
b60ce6cda3
12 files changed
+707
-11
No files matched your search
@@ -0,0 +1,65 @@
|
|||||||
|
// Android build for the native-call plugin. Mirrors the official @capacitor/* plugin module layout (see
|
||||||
|
// node_modules/@capacitor/status-bar/android/build.gradle) so it drops into the CI-generated app exactly like
|
||||||
|
// the plugins that already build. Java (not Kotlin) to match the proven official-plugin structure — no extra
|
||||||
|
// kotlin-gradle-plugin wiring in the library module, one less thing to fail when authored blind.
|
||||||
|
ext {
|
||||||
|
junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
|
||||||
|
androidxCoreVersion = project.hasProperty('androidxCoreVersion') ? rootProject.ext.androidxCoreVersion : '1.12.0'
|
||||||
|
androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.6.1'
|
||||||
|
// Same FCM version the @capacitor/push-notifications plugin pulls in, so we don't fork the Firebase version.
|
||||||
|
firebaseMessagingVersion = project.hasProperty('firebaseMessagingVersion') ? rootProject.ext.firebaseMessagingVersion : '23.3.1'
|
||||||
|
}
|
||||||
|
|
||||||
|
buildscript {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath 'com.android.tools.build:gradle:8.2.1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply plugin: 'com.android.library'
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "com.bizgaze.connect.nativecall"
|
||||||
|
compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34
|
||||||
|
defaultConfig {
|
||||||
|
minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
|
||||||
|
targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0"
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lintOptions {
|
||||||
|
abortOnError false
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||||
|
implementation project(':capacitor-android')
|
||||||
|
// The Biz Connect call-wake FirebaseMessagingService extends the @capacitor/push-notifications one so a
|
||||||
|
// single service handles BOTH regular push (forwarded via super) and call-wake data messages.
|
||||||
|
implementation project(':capacitor-push-notifications')
|
||||||
|
implementation "androidx.core:core:$androidxCoreVersion"
|
||||||
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
|
// RemoteMessage / FirebaseMessaging (transitive from the push plugin, but declared so this module compiles
|
||||||
|
// against it directly; same version → no resolution conflict).
|
||||||
|
implementation "com.google.firebase:firebase-messaging:$firebaseMessagingVersion"
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<!-- Full-screen incoming-call UI over the lock screen (Android's answer to CallKit). VIBRATE + WAKE_LOCK
|
||||||
|
for the ring; POST_NOTIFICATIONS is also added app-side by android-patch.js. -->
|
||||||
|
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
|
<application>
|
||||||
|
<!-- Our FirebaseMessagingService REPLACES the @capacitor/push-notifications one: it handles call-wake
|
||||||
|
DATA messages natively (to ring when the app is closed) and forwards everything else to Capacitor's
|
||||||
|
push handling via super.onMessageReceived(). Capacitor's service is removed in the APP manifest
|
||||||
|
(android-patch.js, highest merge priority) so there is exactly one MESSAGING_EVENT receiver; the high
|
||||||
|
intent-filter priority here is belt-and-braces so ours wins even if that removal ever no-ops. -->
|
||||||
|
<service
|
||||||
|
android:name="com.bizgaze.connect.nativecall.BzCallMessagingService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter android:priority="999">
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- The full-screen incoming-call screen. showWhenLocked + turnScreenOn make it appear over the lock
|
||||||
|
screen and wake the display; excludeFromRecents keeps it out of the recents list. -->
|
||||||
|
<activity
|
||||||
|
android:name="com.bizgaze.connect.nativecall.IncomingCallActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:showOnLockScreen="true"
|
||||||
|
android:showWhenLocked="true"
|
||||||
|
android:turnScreenOn="true"
|
||||||
|
android:theme="@android:style/Theme.DeviceDefault.NoActionBar" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
package com.bizgaze.connect.nativecall;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import com.google.firebase.messaging.RemoteMessage;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
// The single FirebaseMessagingService for the app. It REPLACES @capacitor/push-notifications' MessagingService
|
||||||
|
// (removed in AndroidManifest via tools:node="remove") so there is exactly one MESSAGING_EVENT receiver:
|
||||||
|
// * call-wake DATA messages (data.bzcall == "1") → ring natively, even when the app is closed. A high-priority
|
||||||
|
// data-only FCM message is delivered to onMessageReceived in any app state (a "notification" message would
|
||||||
|
// just sit in the tray and never run our code).
|
||||||
|
// * everything else → super.onMessageReceived(), i.e. Capacitor's normal push handling (chat/meeting banners).
|
||||||
|
// onNewToken is likewise forwarded so the web still registers this device's "android" FCM token — the same token
|
||||||
|
// the server targets for call-wakes.
|
||||||
|
public class BzCallMessagingService extends com.capacitorjs.plugins.pushnotifications.MessagingService {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMessageReceived(@NonNull RemoteMessage remoteMessage) {
|
||||||
|
Map<String, String> data = remoteMessage.getData();
|
||||||
|
if (data != null && "1".equals(data.get("bzcall"))) {
|
||||||
|
String type = data.get("type");
|
||||||
|
String uuid = data.get("callUUID");
|
||||||
|
if ("cancel".equals(type)) {
|
||||||
|
CallStore.markEnded(uuid);
|
||||||
|
CallStore.cancel(getApplicationContext());
|
||||||
|
IncomingCallActivity.dismissIfShowing();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, String> call = new HashMap<>();
|
||||||
|
call.put("callUUID", uuid == null ? "" : uuid);
|
||||||
|
call.put("room", or(data.get("room")));
|
||||||
|
call.put("kind", data.get("kind") == null ? "dm" : data.get("kind"));
|
||||||
|
call.put("callerId", or(data.get("callerId")));
|
||||||
|
call.put("callerName", data.get("callerName") != null ? data.get("callerName")
|
||||||
|
: (data.get("groupName") != null ? data.get("groupName") : "Incoming call"));
|
||||||
|
call.put("groupId", or(data.get("groupId")));
|
||||||
|
call.put("groupName", or(data.get("groupName")));
|
||||||
|
// Server sends hasVideo as "1"/""; normalize to "true"/"false" so the UI's checks are consistent
|
||||||
|
// with the WebSocket path (reportIncomingCall stores Boolean.toString()).
|
||||||
|
String hv = data.get("hasVideo");
|
||||||
|
call.put("hasVideo", ("1".equals(hv) || "true".equals(hv)) ? "true" : "false");
|
||||||
|
CallStore.showIncoming(getApplicationContext(), call);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
super.onMessageReceived(remoteMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNewToken(@NonNull String s) {
|
||||||
|
super.onNewToken(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String or(String v) { return v == null ? "" : v; }
|
||||||
|
}
|
||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
package com.bizgaze.connect.nativecall;
|
||||||
|
|
||||||
|
import android.app.NotificationChannel;
|
||||||
|
import android.app.NotificationManager;
|
||||||
|
import android.app.PendingIntent;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.media.AudioAttributes;
|
||||||
|
import android.media.RingtoneManager;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Build;
|
||||||
|
|
||||||
|
import androidx.core.app.NotificationCompat;
|
||||||
|
import androidx.core.app.NotificationManagerCompat;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
// Builds and shows the full-screen incoming-call notification (Android's stand-in for CallKit's system ring),
|
||||||
|
// and dedupes calls so the FCM push path and the WebSocket path don't ring the same call twice. One call at a
|
||||||
|
// time is assumed (a single NOTIF_ID), matching the app's 1:1 / single-group-call model.
|
||||||
|
class CallStore {
|
||||||
|
|
||||||
|
static final String CHANNEL_ID = "bzc_incoming_calls";
|
||||||
|
static final int NOTIF_ID = 2748;
|
||||||
|
|
||||||
|
static final String ACTION_RING = "com.bizgaze.connect.nativecall.RING";
|
||||||
|
static final String ACTION_ACCEPT = "com.bizgaze.connect.nativecall.ACCEPT";
|
||||||
|
static final String ACTION_DECLINE = "com.bizgaze.connect.nativecall.DECLINE";
|
||||||
|
|
||||||
|
// callUUIDs currently ringing/active and ones we've already ended — so a late/duplicate wake doesn't re-ring.
|
||||||
|
private static final java.util.Set<String> active = ConcurrentHashMap.newKeySet();
|
||||||
|
private static final java.util.Set<String> ended = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
|
static boolean isKnown(String uuid) { return uuid != null && (active.contains(uuid) || ended.contains(uuid)); }
|
||||||
|
static void markActive(String uuid) { if (uuid != null) active.add(uuid); }
|
||||||
|
static void markEnded(String uuid) { if (uuid != null) { active.remove(uuid); ended.add(uuid); } }
|
||||||
|
|
||||||
|
private static void ensureChannel(Context ctx) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
|
||||||
|
NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
|
||||||
|
if (nm == null || nm.getNotificationChannel(CHANNEL_ID) != null) return;
|
||||||
|
NotificationChannel ch = new NotificationChannel(CHANNEL_ID, "Incoming calls", NotificationManager.IMPORTANCE_HIGH);
|
||||||
|
ch.setDescription("Ringing for incoming Biz Connect calls");
|
||||||
|
Uri ring = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE);
|
||||||
|
AudioAttributes attrs = new AudioAttributes.Builder()
|
||||||
|
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
||||||
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
|
.build();
|
||||||
|
if (ring != null) ch.setSound(ring, attrs);
|
||||||
|
ch.enableVibration(true);
|
||||||
|
ch.setVibrationPattern(new long[]{0, 700, 700, 700, 700});
|
||||||
|
ch.setLockscreenVisibility(NotificationCompat.VISIBILITY_PUBLIC);
|
||||||
|
nm.createNotificationChannel(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Intent activityIntent(Context ctx, String action, Map<String, String> call) {
|
||||||
|
Intent i = new Intent(ctx, IncomingCallActivity.class);
|
||||||
|
i.setAction(action);
|
||||||
|
i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||||
|
for (Map.Entry<String, String> e : call.entrySet()) i.putExtra(e.getKey(), e.getValue());
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PendingIntent activityPI(Context ctx, String action, int req, Map<String, String> call) {
|
||||||
|
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) flags |= PendingIntent.FLAG_IMMUTABLE;
|
||||||
|
return PendingIntent.getActivity(ctx, req, activityIntent(ctx, action, call), flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the ringing notification with a full-screen intent (launches IncomingCallActivity over the lock
|
||||||
|
// screen when allowed; otherwise shows as a heads-up with Accept/Decline buttons).
|
||||||
|
static void showIncoming(Context ctx, Map<String, String> call) {
|
||||||
|
String uuid = call.get("callUUID");
|
||||||
|
if (uuid == null || ended.contains(uuid)) return;
|
||||||
|
active.add(uuid);
|
||||||
|
ensureChannel(ctx);
|
||||||
|
|
||||||
|
String title = call.get("callerName");
|
||||||
|
if (title == null || title.isEmpty()) title = "Incoming call";
|
||||||
|
boolean video = "true".equals(call.get("hasVideo"));
|
||||||
|
|
||||||
|
PendingIntent full = activityPI(ctx, ACTION_RING, 1, call);
|
||||||
|
PendingIntent accept = activityPI(ctx, ACTION_ACCEPT, 2, call);
|
||||||
|
PendingIntent decline = activityPI(ctx, ACTION_DECLINE, 3, call);
|
||||||
|
|
||||||
|
NotificationCompat.Builder b = new NotificationCompat.Builder(ctx, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.sym_call_incoming)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(video ? "Incoming video call" : "Incoming call")
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setAutoCancel(false)
|
||||||
|
.setContentIntent(full)
|
||||||
|
.setFullScreenIntent(full, true)
|
||||||
|
.addAction(0, "Decline", decline)
|
||||||
|
.addAction(0, "Accept", accept);
|
||||||
|
|
||||||
|
try {
|
||||||
|
NotificationManagerCompat.from(ctx).notify(NOTIF_ID, b.build());
|
||||||
|
} catch (SecurityException ignored) { /* POST_NOTIFICATIONS not granted */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
static void cancel(Context ctx) {
|
||||||
|
try { NotificationManagerCompat.from(ctx).cancel(NOTIF_ID); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
+205
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
package com.bizgaze.connect.nativecall;
|
||||||
|
|
||||||
|
import com.getcapacitor.JSObject;
|
||||||
|
import com.getcapacitor.Plugin;
|
||||||
|
import com.getcapacitor.PluginCall;
|
||||||
|
import com.getcapacitor.PluginMethod;
|
||||||
|
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||||
|
import com.google.firebase.messaging.FirebaseMessaging;
|
||||||
|
|
||||||
|
// Native calling for Biz Connect (Android). Registered by `cap sync` as window.Capacitor.Plugins.NativeCall —
|
||||||
|
// the SAME jsName the iOS plugin uses, so the web bridge (setupNativeCall in home.html) drives both platforms
|
||||||
|
// through one code path.
|
||||||
|
//
|
||||||
|
// INCREMENT 1 (ring + answer): rings incoming calls with a full-screen-over-the-lock-screen UI (Android's
|
||||||
|
// answer to CallKit), woken by a high-priority FCM DATA message even when the app is closed
|
||||||
|
// (BzCallMessagingService). Answer/decline are bridged back to the WebView, which joins the meeting the normal
|
||||||
|
// way — MEDIA STILL RUNS IN THE WEBVIEW for now (works in the foreground). Background call audio via a native
|
||||||
|
// LiveKit connection + a foreground service is Increment 2, mirroring the iOS build history.
|
||||||
|
//
|
||||||
|
// The video / screen-share / hole-punch / transcription methods the iOS plugin implements are declared here as
|
||||||
|
// safe no-ops so the shared web bridge never rejects when it calls them on Android; they get real bodies in the
|
||||||
|
// later increments.
|
||||||
|
@CapacitorPlugin(name = "NativeCall")
|
||||||
|
public class NativeCallPlugin extends Plugin {
|
||||||
|
|
||||||
|
// The live plugin instance + a buffered answer for the cold-start case (a call answered from a killed app:
|
||||||
|
// the answer arrives before the WebView/JS has attached a listener). notifyListeners(..., retain=true) also
|
||||||
|
// buffers, but when the plugin itself isn't loaded yet we hold the event here and flush it in load().
|
||||||
|
private static NativeCallPlugin instance;
|
||||||
|
private static JSObject pendingAnswer;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void load() {
|
||||||
|
instance = this;
|
||||||
|
if (pendingAnswer != null) {
|
||||||
|
JSObject a = pendingAnswer;
|
||||||
|
pendingAnswer = null;
|
||||||
|
notifyListeners("answerCall", a, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void handleOnDestroy() {
|
||||||
|
super.handleOnDestroy();
|
||||||
|
if (instance == this) instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called from IncomingCallActivity when the user accepts. Fires the shared "answerCall" event the web
|
||||||
|
// listens for; retained so a killed/locked pickup isn't lost before the WebView JS attaches.
|
||||||
|
static void deliverAnswer(JSObject data) {
|
||||||
|
if (instance != null) instance.notifyListeners("answerCall", data, true);
|
||||||
|
else pendingAnswer = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called on decline / remote hang-up. The web ends/declines the call server-side.
|
||||||
|
static void deliverEnd(JSObject data) {
|
||||||
|
if (instance != null) instance.notifyListeners("endCall", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JS-callable methods
|
||||||
|
|
||||||
|
// The FCM registration token — the same token @capacitor/push-notifications registers as an "android"
|
||||||
|
// device, which is exactly what the server call-wake targets. Returned so the web can confirm/re-register.
|
||||||
|
@PluginMethod
|
||||||
|
public void getToken(final PluginCall call) {
|
||||||
|
FirebaseMessaging.getInstance().getToken().addOnCompleteListener(task -> {
|
||||||
|
JSObject ret = new JSObject();
|
||||||
|
ret.put("token", task.isSuccessful() && task.getResult() != null ? task.getResult() : "");
|
||||||
|
call.resolve(ret);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ring CallKit-style from a WebSocket call event (a 2nd path alongside the FCM push, for when the app is
|
||||||
|
// open). Deduped by callUUID in CallStore, so double-firing with the push is harmless.
|
||||||
|
@PluginMethod
|
||||||
|
public void reportIncomingCall(PluginCall call) {
|
||||||
|
String uuid = call.getString("callUUID", "");
|
||||||
|
if (uuid.isEmpty()) { call.reject("callUUID required"); return; }
|
||||||
|
if (CallStore.isKnown(uuid)) { call.resolve(); return; }
|
||||||
|
java.util.Map<String, String> c = new java.util.HashMap<>();
|
||||||
|
c.put("callUUID", uuid);
|
||||||
|
c.put("room", call.getString("room", ""));
|
||||||
|
c.put("kind", call.getString("kind", "dm"));
|
||||||
|
c.put("callerId", call.getString("callerId", ""));
|
||||||
|
c.put("callerName", call.getString("callerName", call.getString("groupName", "Incoming call")));
|
||||||
|
c.put("groupId", call.getString("groupId", ""));
|
||||||
|
c.put("groupName", call.getString("groupName", ""));
|
||||||
|
c.put("hasVideo", Boolean.toString(Boolean.TRUE.equals(call.getBoolean("hasVideo", false))));
|
||||||
|
CallStore.showIncoming(getContext(), c);
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outgoing call from the web app. Increment 1: media stays in the WebView, so there is nothing to ring or
|
||||||
|
// connect natively here — the callee is rung by the server's FCM call-wake. Just record it so a later
|
||||||
|
// endCall/cancel can match. (Increment 2 will start a foreground service + native LiveKit here.)
|
||||||
|
@PluginMethod
|
||||||
|
public void reportOutgoingCall(PluginCall call) {
|
||||||
|
String uuid = call.getString("callUUID", "");
|
||||||
|
if (!uuid.isEmpty()) CallStore.markActive(uuid);
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop ringing / clear the incoming UI for a call (remote hung up, we ended, or another device answered).
|
||||||
|
@PluginMethod
|
||||||
|
public void endCall(PluginCall call) {
|
||||||
|
String uuid = call.getString("callUUID", "");
|
||||||
|
if (!uuid.isEmpty()) CallStore.markEnded(uuid);
|
||||||
|
CallStore.cancel(getContext());
|
||||||
|
IncomingCallActivity.dismissIfShowing();
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment 1: mute is handled by the WebView (it owns the media). No-op so the shared bridge doesn't reject.
|
||||||
|
@PluginMethod public void setMuted(PluginCall call) { call.resolve(); }
|
||||||
|
|
||||||
|
// --- Increment 2+ methods (video / screen-share / hole-punch / #12 reconnect / #5 transcript). Safe no-ops
|
||||||
|
// for now so the shared web bridge never rejects on Android; real bodies land with the native-media build. ---
|
||||||
|
@PluginMethod public void reconnectRoom(PluginCall call) { call.resolve(); }
|
||||||
|
@PluginMethod public void setCamera(PluginCall call) { call.resolve(); }
|
||||||
|
@PluginMethod public void switchCamera(PluginCall call) { call.resolve(); }
|
||||||
|
@PluginMethod public void startScreenShare(PluginCall call) { call.resolve(); }
|
||||||
|
@PluginMethod public void stopScreenShare(PluginCall call) { call.resolve(); }
|
||||||
|
@PluginMethod public void startMeetingScreenShare(PluginCall call) { call.resolve(); }
|
||||||
|
@PluginMethod public void stopMeetingScreenShare(PluginCall call) { call.resolve(); }
|
||||||
|
@PluginMethod public void setHolePunch(PluginCall call) { call.resolve(); }
|
||||||
|
@PluginMethod public void setTileZoom(PluginCall call) { call.resolve(); }
|
||||||
|
@PluginMethod public void syncVideoTiles(PluginCall call) { call.resolve(); }
|
||||||
|
@PluginMethod public void startTranscription(PluginCall call) { call.resolve(); }
|
||||||
|
@PluginMethod public void stopTranscription(PluginCall call) { call.resolve(); }
|
||||||
|
@PluginMethod public void feedAudio(PluginCall call) { call.resolve(); }
|
||||||
|
}
|
||||||
@@ -10,12 +10,17 @@
|
|||||||
"files": [
|
"files": [
|
||||||
"dist/",
|
"dist/",
|
||||||
"ios/",
|
"ios/",
|
||||||
|
"android/src/main/",
|
||||||
|
"android/build.gradle",
|
||||||
"NativeCall.podspec",
|
"NativeCall.podspec",
|
||||||
"Package.swift"
|
"Package.swift"
|
||||||
],
|
],
|
||||||
"capacitor": {
|
"capacitor": {
|
||||||
"ios": {
|
"ios": {
|
||||||
"src": "ios"
|
"src": "ios"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"src": "android"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -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); }
|
if (!p || !fs.existsSync(p)) { console.log(' (AndroidManifest.xml not found — permission patch skipped)'); process.exit(0); }
|
||||||
try {
|
try {
|
||||||
let s = fs.readFileSync(p, 'utf8');
|
let s = fs.readFileSync(p, 'utf8');
|
||||||
|
|
||||||
|
// (1) Native-call FirebaseMessagingService swap. The native-call plugin declares its own MESSAGING_EVENT
|
||||||
|
// service (BzCallMessagingService) that handles call-wake data messages AND forwards regular push to Capacitor
|
||||||
|
// via super. Remove @capacitor/push-notifications' own MessagingService here — the APP manifest is the highest
|
||||||
|
// merge priority, so this reliably guarantees exactly one receiver. Needs the `tools` namespace + an insert
|
||||||
|
// inside <application>. Keyed on bzcCallSvcRemove so re-runs never duplicate it.
|
||||||
|
if (!s.includes('bzcCallSvcRemove')) {
|
||||||
|
// Ensure xmlns:tools is declared on <manifest> (tools:node="remove" needs it).
|
||||||
|
if (!/<manifest[^>]*xmlns:tools=/.test(s)) {
|
||||||
|
s = s.replace(/<manifest(\s)/, '<manifest xmlns:tools="http://schemas.android.com/tools"$1');
|
||||||
|
}
|
||||||
|
const removal = [
|
||||||
|
'',
|
||||||
|
' <!-- bzcCallSvcRemove: the native-call plugin\'s BzCallMessagingService replaces this one (it forwards regular push via super) -->',
|
||||||
|
' <service android:name="com.capacitorjs.plugins.pushnotifications.MessagingService" tools:node="remove" />',
|
||||||
|
' ',
|
||||||
|
].join('\n');
|
||||||
|
const before = s;
|
||||||
|
s = s.replace(/<\/application>/, removal + '</application>');
|
||||||
|
if (s !== before) console.log(' Capacitor MessagingService removal injected (native-call takes over MESSAGING_EVENT)');
|
||||||
|
else console.log(' could not find </application> — MessagingService removal skipped');
|
||||||
|
fs.writeFileSync(p, s);
|
||||||
|
} else {
|
||||||
|
console.log(' Capacitor MessagingService removal already present');
|
||||||
|
}
|
||||||
|
|
||||||
|
// (2) WebView permissions.
|
||||||
if (s.includes('bzcAndroidPerms') || s.includes('android.permission.RECORD_AUDIO')) {
|
if (s.includes('bzcAndroidPerms') || s.includes('android.permission.RECORD_AUDIO')) {
|
||||||
console.log(' Android permissions already present'); process.exit(0);
|
console.log(' Android permissions already present'); process.exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
// 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';
|
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 = {
|
module.exports = {
|
||||||
PORT: process.env.PORT || 8090,
|
PORT: process.env.PORT || 8090,
|
||||||
HTTPS_PORT: process.env.HTTPS_PORT || 8443,
|
HTTPS_PORT: process.env.HTTPS_PORT || 8443,
|
||||||
@@ -65,6 +72,7 @@ module.exports = {
|
|||||||
PUBLIC_BASE_URL,
|
PUBLIC_BASE_URL,
|
||||||
GIPHY_API_KEY,
|
GIPHY_API_KEY,
|
||||||
CALLKIT_ENABLED,
|
CALLKIT_ENABLED,
|
||||||
|
ANDROID_CALLS,
|
||||||
PUBLIC_DIR,
|
PUBLIC_DIR,
|
||||||
REC_DIR,
|
REC_DIR,
|
||||||
TRANS_DIR,
|
TRANS_DIR,
|
||||||
|
|||||||
+22
-8
@@ -2976,7 +2976,7 @@ function refreshGroupRowTick(gid){
|
|||||||
}
|
}
|
||||||
// Shared group call: start it (or join the live one — the server returns the existing room).
|
// Shared group call: start it (or join the live one — the server returns the existing room).
|
||||||
async function startOrJoinGroupCall(group){
|
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'); }
|
catch(e){ toast(e.message||'Could not start the call'); }
|
||||||
}
|
}
|
||||||
function updateCallBtn(active){ const cc=document.getElementById('convoCall'); if(!cc) return; cc.classList.toggle('joinable',active); cc.title=active?'Join call':'Start call'; cc.innerHTML=ic(active?'video':'phone',18)+(active?'<span>Join</span>':''); }
|
function updateCallBtn(active){ const cc=document.getElementById('convoCall'); if(!cc) return; cc.classList.toggle('joinable',active); cc.title=active?'Join call':'Start call'; cc.innerHTML=ic(active?'video':'phone',18)+(active?'<span>Join</span>':''); }
|
||||||
@@ -2993,7 +2993,7 @@ function onGroupCall(d){
|
|||||||
function dismissCallInvite(room){ if(!room) return; const el=document.getElementById('ci-'+room); if(el){ try{ el.remove(); }catch(_){} stopRing(); } }
|
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.
|
// 1:1 call: start/join from the DM header; live state updates the button + shows an incoming invite.
|
||||||
async function startOrJoinDmCall(otherId){
|
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'); }
|
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
|
// 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
|
// a CallKit device the SYSTEM owns the incoming ring, so we suppress the in-app call-invite popup
|
||||||
// (onDmCall / the group invite check nativeCallOn()).
|
// (onDmCall / the group invite check nativeCallOn()).
|
||||||
let _callkitReady=false;
|
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)
|
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 nativeCallPlugin(){ const P=window.Capacitor&&window.Capacitor.Plugins; return (P&&P.NativeCall)||null; }
|
||||||
function nativeCallOn(){ return _callkitReady; }
|
function nativeCallOn(){ return _callkitReady; }
|
||||||
async function setupNativeCall(){
|
async function setupNativeCall(){
|
||||||
const NC=nativeCallPlugin(); if(!NC) return;
|
const NC=nativeCallPlugin(); if(!NC) return;
|
||||||
// Server kill-switch: only take over calls with CallKit when the server enables it (callkit:true). When
|
// Server kill-switch, per platform, so calls only go native when the server enables it (WebView flow otherwise).
|
||||||
// off, calls use the WebView flow (mic works). Flipped on only once native media carries the audio.
|
const plat=nativePlatform();
|
||||||
let cfg={}; try{ cfg=await fetch('/api/meetings/config').then(r=>r.json()); }catch(_){}
|
let cfg={}; try{ cfg=await fetch('/api/meetings/config').then(r=>r.json()); }catch(_){}
|
||||||
|
if(plat==='android'){
|
||||||
|
// Android Increment 1: native full-screen ring (over the lock screen, wakes from closed via FCM data), but
|
||||||
|
// the WebView still carries the call media. Gated on androidCall (server ANDROID_CALLS=1). The 'android' FCM
|
||||||
|
// token is already registered by setupNativePush — the server call-wake targets it, so no token step here.
|
||||||
|
if(!cfg || !cfg.androidCall){ console.log('[nativecall] Android native calling disabled by server — using WebView calls'); return; }
|
||||||
|
_callkitReady=true; _callWebViewMedia=true;
|
||||||
|
} else {
|
||||||
|
// iOS CallKit: native media (the plugin owns this identity's one LiveKit connection). Flipped on once native
|
||||||
|
// media carries the audio.
|
||||||
if(!cfg || !cfg.callkit){ console.log('[callkit] disabled by server — using WebView calls'); return; }
|
if(!cfg || !cfg.callkit){ console.log('[callkit] disabled by server — using WebView calls'); return; }
|
||||||
_callkitReady=true;
|
_callkitReady=true;
|
||||||
// VoIP (PushKit) token → register as an 'ios-voip' device so the server sends CallKit wake pushes.
|
// 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)); });
|
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(_){}
|
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
|
// 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
|
// (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
|
// 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);
|
_nativeAnswered.add(d.room);
|
||||||
dismissCallInvite(d.room);
|
dismissCallInvite(d.room);
|
||||||
// Open the REAL meeting window. Joining the mesh marks the call answered server-side, shows the caller's
|
// 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
|
// tile, and wires the end-both-sides lifecycle. meetReturn = the chat to land back on when the call ends.
|
||||||
// native (no 2nd SFU connection). meetReturn = the chat to land back on when the call ends.
|
|
||||||
const isGroup=(d.kind==='group');
|
const isGroup=(d.kind==='group');
|
||||||
meetReturn = isGroup ? { kind:'group', id:d.groupId } : { kind:'dm', id:d.callerId };
|
meetReturn = isGroup ? { kind:'group', id:d.groupId } : { kind:'dm', id:d.callerId };
|
||||||
switchTab('meeting'); enterMeeting(d.room, false, { native:true, uuid:d.callUUID });
|
switchTab('meeting');
|
||||||
}catch(e){ console.warn('[callkit] answerCall failed:', e); } });
|
// 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
|
// 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.
|
// 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
|
NC.addListener('callConnected', ()=>{ if(meetNative){ try{ NC.setMuted({ muted:!meetMic }); }catch(_){} if(meetCam){ try{ NC.setCamera({ on:true }); }catch(_){} } } }); // re-applies mic/cam after a #12 media reconnect too
|
||||||
|
|||||||
+40
-1
@@ -69,6 +69,19 @@ async function sendFcm(token, payload) {
|
|||||||
return { ok: res.ok, dead: res.status === 404 || res.status === 410 }; // UNREGISTERED tokens
|
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 ----------------
|
// ---------------- APNs (iOS), token-based over HTTP/2 ----------------
|
||||||
let apnsKey = null, apnsCfg = null;
|
let apnsKey = null, apnsCfg = null;
|
||||||
(function loadApns() {
|
(function loadApns() {
|
||||||
@@ -160,6 +173,23 @@ async function sendCallNotification(userId, data) {
|
|||||||
}
|
}
|
||||||
return 'voip';
|
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, {
|
await sendToUser(userId, {
|
||||||
title: data.title, body: data.body, kind: data.kind, id: data.callerId || data.groupId,
|
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 },
|
room: data.room, tag: 'call:' + data.room, data: { kind: data.kind, id: data.callerId || data.groupId, room: data.room, call: 1 },
|
||||||
@@ -171,13 +201,22 @@ async function sendCallNotification(userId, data) {
|
|||||||
// VoIP push so it reaches a killed app that has no WebSocket — the CallKit plugin ends the reported call.
|
// 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).
|
// No-op for non-VoIP devices (their ring is a normal notification that just goes away).
|
||||||
async function sendCallCancel(userId, callUUID) {
|
async function sendCallCancel(userId, callUUID) {
|
||||||
if (!apnsCfg || !callUUID || process.env.CALLKIT_ENABLED !== '1') return;
|
if (!callUUID) return;
|
||||||
let toks = [];
|
let toks = [];
|
||||||
try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
|
try { toks = await R.deviceTokens.byUser(userId); } catch (_) { toks = []; }
|
||||||
|
// iOS: cancel the CallKit ring via a VoIP push.
|
||||||
|
if (apnsCfg && process.env.CALLKIT_ENABLED === '1') {
|
||||||
for (const t of toks.filter((t) => t.platform === 'ios-voip')) {
|
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 (_) {}
|
try { const r = await sendApnsVoip(t.token, { type: 'cancel', callUUID }); if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Android: cancel the native ring via a high-priority data message (BzCallMessagingService dismisses it).
|
||||||
|
if (fcmSA && process.env.ANDROID_CALLS === '1') {
|
||||||
|
for (const t of toks.filter((t) => t.platform === 'android')) {
|
||||||
|
try { const r = await sendFcmData(t.token, { bzcall: '1', type: 'cancel', callUUID }); if (r && r.dead) { try { await R.deviceTokens.removeByToken(t.token); } catch (_) {} } } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------- public API ----------------
|
// ---------------- public API ----------------
|
||||||
const nativeReady = !!(fcmSA || apnsCfg);
|
const nativeReady = !!(fcmSA || apnsCfg);
|
||||||
|
|||||||
+2
-2
@@ -116,7 +116,7 @@ const API_KEY_SCOPES = ['report:read', 'audit:read'];
|
|||||||
const { onlineAgents, meetingRooms, groupCalls, dmCalls } = require('./presence');
|
const { onlineAgents, meetingRooms, groupCalls, dmCalls } = require('./presence');
|
||||||
const CALLS = require('./calls');
|
const CALLS = require('./calls');
|
||||||
require('./reminders'); // start the 10-minute meeting-reminder loop
|
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');
|
const https = require('https');
|
||||||
// Small GET-JSON helper for the GIPHY proxy (keeps the key server-side).
|
// Small GET-JSON helper for the GIPHY proxy (keeps the key server-side).
|
||||||
function fetchJSON(url) {
|
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
|
// 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://…).
|
// false it uses the built-in P2P mesh. url is the browser-facing signaling endpoint (wss://…).
|
||||||
route('GET', '/api/meetings/config', (req, res) => {
|
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.
|
// #5 GIF search — server-side GIPHY proxy so the API key never reaches the browser. Empty q → trending.
|
||||||
|
|||||||
Reference in new issue
Block a user