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:
SravanandClaude Opus 4.8 committed 2026-09-28 18:53:06 +05:30
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.VIBRATE" />
<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>
<!-- Our FirebaseMessagingService REPLACES the @capacitor/push-notifications one: it handles call-wake
@@ -1,5 +1,7 @@
package com.bizgaze.connect.nativecall;
import android.util.Log;
import androidx.annotation.NonNull;
import com.google.firebase.messaging.RemoteMessage;
@@ -20,6 +22,8 @@ public class BzCallMessagingService extends com.capacitorjs.plugins.pushnotifica
@Override
public void onMessageReceived(@NonNull RemoteMessage remoteMessage) {
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"))) {
String type = data.get("type");
String uuid = data.get("callUUID");
@@ -5,10 +5,8 @@ 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 android.util.Log;
import androidx.core.app.NotificationCompat;
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.
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 String ACTION_RING = "com.bizgaze.connect.nativecall.RING";
@@ -42,14 +43,9 @@ class CallStore {
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});
// Silent + no vibration: IncomingCallActivity owns the ring + vibration (single source, avoids overlap).
ch.setSound(null, null);
ch.enableVibration(false);
ch.setLockscreenVisibility(NotificationCompat.VISIBILITY_PUBLIC);
nm.createNotificationChannel(ch);
}
@@ -72,8 +68,11 @@ class CallStore {
// 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);
Log.i("bzcall", "showIncoming uuid=" + uuid + " caller=" + call.get("callerName"));
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);
String title = call.get("callerName");
@@ -91,6 +90,7 @@ class CallStore {
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOnlyAlertOnce(true)
.setOngoing(true)
.setAutoCancel(false)
.setContentIntent(full)
@@ -100,7 +100,10 @@ class CallStore {
try {
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) {
@@ -10,6 +10,7 @@ import android.media.RingtoneManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.Gravity;
import android.view.View;
import android.view.WindowManager;
@@ -27,6 +28,7 @@ public class IncomingCallActivity extends Activity {
private static IncomingCallActivity current;
private Ringtone ringtone;
private android.os.Vibrator vibrator;
private JSObject callData;
private boolean handled = false;
@@ -46,6 +48,7 @@ public class IncomingCallActivity extends Activity {
private void handleIntent(Intent intent) {
String action = intent != null ? intent.getAction() : null;
Log.i("bzcall", "IncomingCallActivity action=" + action);
callData = extras(intent);
if (CallStore.ACTION_ACCEPT.equals(action)) { doAccept(); return; }
if (CallStore.ACTION_DECLINE.equals(action)) { doDecline(); return; }
@@ -188,11 +191,23 @@ public class IncomingCallActivity extends Activity {
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();
if (uri != null) {
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) {}
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) {}
}
@@ -200,6 +215,8 @@ public class IncomingCallActivity extends Activity {
private void stopRinging() {
try { if (ringtone != null && ringtone.isPlaying()) ringtone.stop(); } catch (Exception ignored) {}
ringtone = null;
try { if (vibrator != null) vibrator.cancel(); } catch (Exception ignored) {}
vibrator = null;
}
private void doAccept() {
@@ -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.
@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
// 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(); }
-3
View File
@@ -4350,9 +4350,6 @@ async function setupNativeCall(){
// 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;
// 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 {
// iOS CallKit: native media (the plugin owns this identity's one LiveKit connection). Flipped on once native
// media carries the audio.