Android calls: single-source ring (fix double-ring), drop battery dialog, add wake logging
On-device (OnePlus, build 5cb3d0c): new ring UI confirmed; backgrounded rang
twice (channel sound + activity ringtone, and both wake paths raised it);
battery dialog deemed unprofessional (WhatsApp/Teams don't prompt it).
- Double-ring: notification channel is now SILENT (new id bzc_incoming_calls_v2,
no sound/vibration, onlyAlertOnce) so IncomingCallActivity is the single ring
source (looping ringtone + vibrator). showIncoming dedups atomically
(if(!active.add(uuid)) return) so the FCM push and WebSocket paths can't both
ring the same call.
- Battery dialog removed: dropped requestCallReliability (web call + plugin
method) and REQUEST_IGNORE_BATTERY_OPTIMIZATIONS permission.
- Logging: Log.i(bzcall, ...) across the wake chain to diagnose swiped-away
(OnePlus force-kills on swipe and blocks FCM cold-start).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
5cb3d0c122
commit
cab0c79f57
6 files changed
+44
-55
No files matched your search
@@ -6,9 +6,6 @@
|
|||||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<!-- Ask to be exempt from battery optimization so high-priority FCM call-wakes reach the app when it's
|
|
||||||
been swiped away / Dozing (OnePlus/Oppo hold data messages otherwise → the ring arrives too late). -->
|
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
|
||||||
|
|
||||||
<application>
|
<application>
|
||||||
<!-- Our FirebaseMessagingService REPLACES the @capacitor/push-notifications one: it handles call-wake
|
<!-- Our FirebaseMessagingService REPLACES the @capacitor/push-notifications one: it handles call-wake
|
||||||
|
|||||||
+4
@@ -1,5 +1,7 @@
|
|||||||
package com.bizgaze.connect.nativecall;
|
package com.bizgaze.connect.nativecall;
|
||||||
|
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
import com.google.firebase.messaging.RemoteMessage;
|
import com.google.firebase.messaging.RemoteMessage;
|
||||||
@@ -20,6 +22,8 @@ public class BzCallMessagingService extends com.capacitorjs.plugins.pushnotifica
|
|||||||
@Override
|
@Override
|
||||||
public void onMessageReceived(@NonNull RemoteMessage remoteMessage) {
|
public void onMessageReceived(@NonNull RemoteMessage remoteMessage) {
|
||||||
Map<String, String> data = remoteMessage.getData();
|
Map<String, String> data = remoteMessage.getData();
|
||||||
|
Log.i("bzcall", "FCM onMessageReceived: bzcall=" + (data == null ? "null" : data.get("bzcall"))
|
||||||
|
+ " type=" + (data == null ? "" : data.get("type")) + " keys=" + (data == null ? "0" : data.keySet()));
|
||||||
if (data != null && "1".equals(data.get("bzcall"))) {
|
if (data != null && "1".equals(data.get("bzcall"))) {
|
||||||
String type = data.get("type");
|
String type = data.get("type");
|
||||||
String uuid = data.get("callUUID");
|
String uuid = data.get("callUUID");
|
||||||
|
|||||||
+18
-15
@@ -5,10 +5,8 @@ import android.app.NotificationManager;
|
|||||||
import android.app.PendingIntent;
|
import android.app.PendingIntent;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.media.AudioAttributes;
|
|
||||||
import android.media.RingtoneManager;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
import androidx.core.app.NotificationCompat;
|
import androidx.core.app.NotificationCompat;
|
||||||
import androidx.core.app.NotificationManagerCompat;
|
import androidx.core.app.NotificationManagerCompat;
|
||||||
@@ -21,7 +19,10 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
// time is assumed (a single NOTIF_ID), matching the app's 1:1 / single-group-call model.
|
// time is assumed (a single NOTIF_ID), matching the app's 1:1 / single-group-call model.
|
||||||
class CallStore {
|
class CallStore {
|
||||||
|
|
||||||
static final String CHANNEL_ID = "bzc_incoming_calls";
|
// v2: SILENT channel. The ring + vibration are driven by IncomingCallActivity (a single, looping source);
|
||||||
|
// if the channel also played a sound we'd get the "ring twice overlapping" the user reported. A channel's
|
||||||
|
// sound is immutable once created, so we bump the id to apply the new silent config on existing installs.
|
||||||
|
static final String CHANNEL_ID = "bzc_incoming_calls_v2";
|
||||||
static final int NOTIF_ID = 2748;
|
static final int NOTIF_ID = 2748;
|
||||||
|
|
||||||
static final String ACTION_RING = "com.bizgaze.connect.nativecall.RING";
|
static final String ACTION_RING = "com.bizgaze.connect.nativecall.RING";
|
||||||
@@ -42,14 +43,9 @@ class CallStore {
|
|||||||
if (nm == null || nm.getNotificationChannel(CHANNEL_ID) != null) return;
|
if (nm == null || nm.getNotificationChannel(CHANNEL_ID) != null) return;
|
||||||
NotificationChannel ch = new NotificationChannel(CHANNEL_ID, "Incoming calls", NotificationManager.IMPORTANCE_HIGH);
|
NotificationChannel ch = new NotificationChannel(CHANNEL_ID, "Incoming calls", NotificationManager.IMPORTANCE_HIGH);
|
||||||
ch.setDescription("Ringing for incoming Biz Connect calls");
|
ch.setDescription("Ringing for incoming Biz Connect calls");
|
||||||
Uri ring = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE);
|
// Silent + no vibration: IncomingCallActivity owns the ring + vibration (single source, avoids overlap).
|
||||||
AudioAttributes attrs = new AudioAttributes.Builder()
|
ch.setSound(null, null);
|
||||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
ch.enableVibration(false);
|
||||||
.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);
|
ch.setLockscreenVisibility(NotificationCompat.VISIBILITY_PUBLIC);
|
||||||
nm.createNotificationChannel(ch);
|
nm.createNotificationChannel(ch);
|
||||||
}
|
}
|
||||||
@@ -72,8 +68,11 @@ class CallStore {
|
|||||||
// screen when allowed; otherwise shows as a heads-up with Accept/Decline buttons).
|
// screen when allowed; otherwise shows as a heads-up with Accept/Decline buttons).
|
||||||
static void showIncoming(Context ctx, Map<String, String> call) {
|
static void showIncoming(Context ctx, Map<String, String> call) {
|
||||||
String uuid = call.get("callUUID");
|
String uuid = call.get("callUUID");
|
||||||
if (uuid == null || ended.contains(uuid)) return;
|
Log.i("bzcall", "showIncoming uuid=" + uuid + " caller=" + call.get("callerName"));
|
||||||
active.add(uuid);
|
if (uuid == null || ended.contains(uuid)) { Log.i("bzcall", "showIncoming skipped (ended/no-uuid)"); return; }
|
||||||
|
// Atomic dedup: add() returns false if already ringing → the FCM push and the WebSocket path can't both
|
||||||
|
// raise the same call (that was the "ring comes twice" bug).
|
||||||
|
if (!active.add(uuid)) { Log.i("bzcall", "showIncoming skipped (already ringing)"); return; }
|
||||||
ensureChannel(ctx);
|
ensureChannel(ctx);
|
||||||
|
|
||||||
String title = call.get("callerName");
|
String title = call.get("callerName");
|
||||||
@@ -91,6 +90,7 @@ class CallStore {
|
|||||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setAutoCancel(false)
|
.setAutoCancel(false)
|
||||||
.setContentIntent(full)
|
.setContentIntent(full)
|
||||||
@@ -100,7 +100,10 @@ class CallStore {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
NotificationManagerCompat.from(ctx).notify(NOTIF_ID, b.build());
|
NotificationManagerCompat.from(ctx).notify(NOTIF_ID, b.build());
|
||||||
} catch (SecurityException ignored) { /* POST_NOTIFICATIONS not granted */ }
|
Log.i("bzcall", "incoming-call notification posted (full-screen intent)");
|
||||||
|
} catch (SecurityException e) {
|
||||||
|
Log.w("bzcall", "notify() blocked — POST_NOTIFICATIONS not granted: " + e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void cancel(Context ctx) {
|
static void cancel(Context ctx) {
|
||||||
|
|||||||
+22
-5
@@ -10,6 +10,7 @@ import android.media.RingtoneManager;
|
|||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.util.Log;
|
||||||
import android.view.Gravity;
|
import android.view.Gravity;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.WindowManager;
|
import android.view.WindowManager;
|
||||||
@@ -27,6 +28,7 @@ public class IncomingCallActivity extends Activity {
|
|||||||
private static IncomingCallActivity current;
|
private static IncomingCallActivity current;
|
||||||
|
|
||||||
private Ringtone ringtone;
|
private Ringtone ringtone;
|
||||||
|
private android.os.Vibrator vibrator;
|
||||||
private JSObject callData;
|
private JSObject callData;
|
||||||
private boolean handled = false;
|
private boolean handled = false;
|
||||||
|
|
||||||
@@ -46,6 +48,7 @@ public class IncomingCallActivity extends Activity {
|
|||||||
|
|
||||||
private void handleIntent(Intent intent) {
|
private void handleIntent(Intent intent) {
|
||||||
String action = intent != null ? intent.getAction() : null;
|
String action = intent != null ? intent.getAction() : null;
|
||||||
|
Log.i("bzcall", "IncomingCallActivity action=" + action);
|
||||||
callData = extras(intent);
|
callData = extras(intent);
|
||||||
if (CallStore.ACTION_ACCEPT.equals(action)) { doAccept(); return; }
|
if (CallStore.ACTION_ACCEPT.equals(action)) { doAccept(); return; }
|
||||||
if (CallStore.ACTION_DECLINE.equals(action)) { doDecline(); return; }
|
if (CallStore.ACTION_DECLINE.equals(action)) { doDecline(); return; }
|
||||||
@@ -188,11 +191,23 @@ public class IncomingCallActivity extends Activity {
|
|||||||
private void startRinging() {
|
private void startRinging() {
|
||||||
try {
|
try {
|
||||||
Uri uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE);
|
Uri uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE);
|
||||||
if (uri == null) return;
|
if (uri != null) {
|
||||||
ringtone = RingtoneManager.getRingtone(getApplicationContext(), uri);
|
ringtone = RingtoneManager.getRingtone(getApplicationContext(), uri);
|
||||||
if (ringtone != null) {
|
if (ringtone != null) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) ringtone.setLooping(true);
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) ringtone.setLooping(true);
|
||||||
ringtone.play();
|
ringtone.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
try {
|
||||||
|
vibrator = (android.os.Vibrator) getSystemService(Context.VIBRATOR_SERVICE);
|
||||||
|
if (vibrator != null && vibrator.hasVibrator()) {
|
||||||
|
long[] pattern = {0, 800, 1000};
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
vibrator.vibrate(android.os.VibrationEffect.createWaveform(pattern, 0)); // 0 = repeat from start
|
||||||
|
} else {
|
||||||
|
vibrator.vibrate(pattern, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception ignored) {}
|
||||||
}
|
}
|
||||||
@@ -200,6 +215,8 @@ public class IncomingCallActivity extends Activity {
|
|||||||
private void stopRinging() {
|
private void stopRinging() {
|
||||||
try { if (ringtone != null && ringtone.isPlaying()) ringtone.stop(); } catch (Exception ignored) {}
|
try { if (ringtone != null && ringtone.isPlaying()) ringtone.stop(); } catch (Exception ignored) {}
|
||||||
ringtone = null;
|
ringtone = null;
|
||||||
|
try { if (vibrator != null) vibrator.cancel(); } catch (Exception ignored) {}
|
||||||
|
vibrator = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void doAccept() {
|
private void doAccept() {
|
||||||
|
|||||||
-29
@@ -113,35 +113,6 @@ public class NativeCallPlugin extends Plugin {
|
|||||||
// Increment 1: mute is handled by the WebView (it owns the media). No-op so the shared bridge doesn't reject.
|
// 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(); }
|
@PluginMethod public void setMuted(PluginCall call) { call.resolve(); }
|
||||||
|
|
||||||
// Ask the OS to exempt the app from battery optimization. Without this, aggressive OEMs (OnePlus/Oppo/Xiaomi)
|
|
||||||
// hold high-priority FCM call-wake messages while the app is swiped-away/Dozing, so an incoming call doesn't
|
|
||||||
// ring until it's already over. Shows the system "allow background activity" dialog once; harmless if already
|
|
||||||
// exempt. (OEM "auto-launch"/"allow background" toggles are separate and can only be enabled by the user.)
|
|
||||||
@PluginMethod
|
|
||||||
public void requestCallReliability(PluginCall call) {
|
|
||||||
com.getcapacitor.JSObject ret = new com.getcapacitor.JSObject();
|
|
||||||
try {
|
|
||||||
android.content.Context ctx = getContext();
|
|
||||||
android.os.PowerManager pm = (android.os.PowerManager) ctx.getSystemService(android.content.Context.POWER_SERVICE);
|
|
||||||
String pkg = ctx.getPackageName();
|
|
||||||
boolean ignoring = pm != null && pm.isIgnoringBatteryOptimizations(pkg);
|
|
||||||
ret.put("ignoringBatteryOptimizations", ignoring);
|
|
||||||
if (!ignoring && Boolean.TRUE.equals(call.getBoolean("prompt", true))) {
|
|
||||||
android.app.Activity act = getActivity();
|
|
||||||
android.content.Context launcher = act != null ? act : ctx;
|
|
||||||
android.content.Intent i = new android.content.Intent(
|
|
||||||
android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
|
|
||||||
android.net.Uri.parse("package:" + pkg));
|
|
||||||
i.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK);
|
|
||||||
launcher.startActivity(i);
|
|
||||||
ret.put("prompted", true);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
ret.put("error", e.getMessage() == null ? "failed" : e.getMessage());
|
|
||||||
}
|
|
||||||
call.resolve(ret);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Increment 2+ methods (video / screen-share / hole-punch / #12 reconnect / #5 transcript). Safe no-ops
|
// --- 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. ---
|
// 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 reconnectRoom(PluginCall call) { call.resolve(); }
|
||||||
|
|||||||
@@ -4350,9 +4350,6 @@ async function setupNativeCall(){
|
|||||||
// token is already registered by setupNativePush — the server call-wake targets it, so no token step here.
|
// 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; }
|
if(!cfg || !cfg.androidCall){ console.log('[nativecall] Android native calling disabled by server — using WebView calls'); return; }
|
||||||
_callkitReady=true; _callWebViewMedia=true;
|
_callkitReady=true; _callWebViewMedia=true;
|
||||||
// Ask for a battery-optimization exemption so a killed/Dozing phone still gets the high-priority FCM
|
|
||||||
// call-wake in time to ring (OnePlus/Oppo hold it otherwise). Prompt only the first time; then just check.
|
|
||||||
try{ if(NC.requestCallReliability){ const asked=localStorage.getItem('bzcCallRelAsked'); const rel=await NC.requestCallReliability({ prompt: !asked }); localStorage.setItem('bzcCallRelAsked','1'); if(rel && rel.ignoringBatteryOptimizations===false) console.log('[nativecall] app is NOT exempt from battery optimization — killed-state ring may be delayed until this is allowed'); } }catch(_){}
|
|
||||||
} else {
|
} else {
|
||||||
// iOS CallKit: native media (the plugin owns this identity's one LiveKit connection). Flipped on once native
|
// iOS CallKit: native media (the plugin owns this identity's one LiveKit connection). Flipped on once native
|
||||||
// media carries the audio.
|
// media carries the audio.
|
||||||
|
|||||||
Reference in new issue
Block a user