Android native calling — Increment 1: full-screen incoming ring + answer

First slice of Android Phase C (full LiveKit parity), mirroring how iOS
started: native full-screen incoming call UI that wakes the app from
closed, with call MEDIA still in the WebView (foreground). Native LiveKit
media + a foreground service for true background audio are Increment 2.

Native (new Android side of the native-call plugin, Java to match the
proven official-plugin module layout):
- NativeCallPlugin: registered as window.Capacitor.Plugins.NativeCall
  (same jsName as iOS, so the shared web bridge drives both). getToken
  (FCM token), reportIncomingCall (ring), reportOutgoingCall, endCall,
  setMuted; video/screen/hole-punch/transcript methods are safe no-ops
  until later increments so the shared bridge never rejects.
- BzCallMessagingService: extends @capacitor/push-notifications'
  MessagingService — handles high-priority call-wake DATA messages
  (data.bzcall==1) to ring when closed, forwards all other push via super.
- IncomingCallActivity: full-screen call screen over the lock screen
  (showWhenLocked/turnScreenOn), Accept/Decline, looping ringtone.
- CallStore: incoming-call notification (channel, full-screen-intent,
  actions) + UUID dedup so the FCM and WebSocket paths don't double-ring.
- Plugin manifest: BzCallMessagingService + IncomingCallActivity + call
  perms (USE_FULL_SCREEN_INTENT/VIBRATE/WAKE_LOCK).
- android-patch.js: removes Capacitor's MessagingService in the APP
  manifest (highest merge priority) so ours is the sole MESSAGING_EVENT
  receiver; ensures the tools namespace. Idempotent.
- package.json: capacitor.android.src wired.

Server (dormant until ANDROID_CALLS=1):
- push.js sendFcmData: high-priority data-only FCM (rings when closed).
  sendCallNotification rings 'android' tokens via it; sendCallCancel too.
- config.js ANDROID_CALLS flag; /api/meetings/config exposes androidCall.

Web (home.html) — platform-aware, iOS/web behavior byte-for-byte unchanged:
- setupNativeCall: Android gated on cfg.androidCall, sets _callWebViewMedia
  (native ring, WebView media); iOS keeps CallKit native-media path.
- answerCall + both outgoing sites branch on _callWebViewMedia so Android
  joins the meeting normally while the callee still rings via the server.

Safe to deploy now: dormant until ANDROID_CALLS=1 AND an APK carrying the
plugin is installed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
SravanandClaude Opus 4.8 committed 2026-09-25 11:44:52 +05:30
1 parent a8e0f549d4
commit b60ce6cda3
12 files changed
+714 -18

No files matched your search

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