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
+40
-51
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
|
||||
|
||||
+4
@@ -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");
|
||||
|
||||
+18
-15
@@ -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) {
|
||||
|
||||
+18
-1
@@ -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,18 +191,32 @@ public class IncomingCallActivity extends Activity {
|
||||
private void startRinging() {
|
||||
try {
|
||||
Uri uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE);
|
||||
if (uri == null) return;
|
||||
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) {}
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
-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.
|
||||
@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(); }
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user