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
+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>
|
||||
+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(); }
|
||||
}
|
||||
Reference in new issue
Block a user