Android calls: ring sounds even when unlocked (call-scoped Ringer)
On an unlocked Android 14 phone the OS shows our call as a heads-up and does NOT launch the full-screen IncomingCallActivity (full-screen-intent is restricted for non-dialer apps while unlocked), so the ring — which lived in the activity — was silent when unlocked (it rang only when locked, where the activity launches). Move the ring to a call-scoped Ringer (looping ringtone + vibration) started in CallStore.showIncoming and stopped in CallStore.cancel (which every answer/decline/cancel/end path already calls). The notification channel stays silent, so there's still a single ring source. IncomingCallActivity no longer rings — it's just UI — and no longer stops the ring on destroy (backgrounding the call screen keeps ringing like a real call until answer/decline/timeout). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
da5b44c0a5
commit
0edbc3f459
3 files changed
+75
-47
No files matched your search
+4
@@ -104,9 +104,13 @@ class CallStore {
|
||||
} catch (SecurityException e) {
|
||||
Log.w("bzcall", "notify() blocked — POST_NOTIFICATIONS not granted: " + e.getMessage());
|
||||
}
|
||||
// Start the ring HERE (not in the activity) so it sounds even when the full-screen screen doesn't launch
|
||||
// — e.g. an unlocked Android 14 phone shows a heads-up instead of our activity.
|
||||
Ringer.start(ctx);
|
||||
}
|
||||
|
||||
static void cancel(Context ctx) {
|
||||
Ringer.stop();
|
||||
try { NotificationManagerCompat.from(ctx).cancel(NOTIF_ID); } catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
+9
-47
@@ -5,9 +5,6 @@ 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.util.Log;
|
||||
@@ -27,8 +24,6 @@ public class IncomingCallActivity extends Activity {
|
||||
|
||||
private static IncomingCallActivity current;
|
||||
|
||||
private Ringtone ringtone;
|
||||
private android.os.Vibrator vibrator;
|
||||
private JSObject callData;
|
||||
private boolean handled = false;
|
||||
|
||||
@@ -52,10 +47,10 @@ public class IncomingCallActivity extends Activity {
|
||||
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.
|
||||
// RING (default): show the UI. The ring itself is driven by Ringer (started in CallStore.showIncoming)
|
||||
// so it sounds even when this activity doesn't launch (unlocked Android 14 shows a heads-up instead).
|
||||
current = this;
|
||||
setContentView(buildUi());
|
||||
startRinging();
|
||||
}
|
||||
|
||||
private JSObject extras(Intent intent) {
|
||||
@@ -188,44 +183,12 @@ public class IncomingCallActivity extends Activity {
|
||||
return col;
|
||||
}
|
||||
|
||||
private void startRinging() {
|
||||
try {
|
||||
Uri uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE);
|
||||
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() {
|
||||
if (handled) { finish(); return; }
|
||||
handled = true;
|
||||
stopRinging();
|
||||
String uuid = callData.getString("callUUID", "");
|
||||
CallStore.markActive(uuid);
|
||||
CallStore.cancel(getApplicationContext());
|
||||
CallStore.cancel(getApplicationContext()); // stops the Ringer + clears the notification
|
||||
// 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());
|
||||
@@ -240,9 +203,8 @@ public class IncomingCallActivity extends Activity {
|
||||
private void doDecline() {
|
||||
if (handled) { finish(); return; }
|
||||
handled = true;
|
||||
stopRinging();
|
||||
CallStore.markEnded(callData.getString("callUUID", ""));
|
||||
CallStore.cancel(getApplicationContext());
|
||||
CallStore.cancel(getApplicationContext()); // stops the Ringer + clears the notification
|
||||
NativeCallPlugin.deliverEnd(callData);
|
||||
finishAndClear();
|
||||
}
|
||||
@@ -254,7 +216,8 @@ public class IncomingCallActivity extends Activity {
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
stopRinging();
|
||||
// Don't stop the ring here — it's call-scoped (Ringer), not screen-scoped. If the user just backgrounds the
|
||||
// call screen without answering, the phone should keep ringing until answer/decline/cancel, like a real call.
|
||||
if (current == this) current = null;
|
||||
super.onDestroy();
|
||||
}
|
||||
@@ -262,11 +225,10 @@ public class IncomingCallActivity extends Activity {
|
||||
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.
|
||||
// Close the ring screen when the call ends elsewhere. The Ringer is stopped by CallStore.cancel on every
|
||||
// end/cancel path, so this only needs to finish the activity.
|
||||
static void dismissIfShowing() {
|
||||
final IncomingCallActivity a = current;
|
||||
if (a != null) a.runOnUiThread(() -> {
|
||||
a.stopRinging();
|
||||
if (!a.isFinishing()) a.finish();
|
||||
});
|
||||
if (a != null) a.runOnUiThread(() -> { if (!a.isFinishing()) a.finish(); });
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.bizgaze.connect.nativecall;
|
||||
|
||||
import android.content.Context;
|
||||
import android.media.AudioAttributes;
|
||||
import android.media.Ringtone;
|
||||
import android.media.RingtoneManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Vibrator;
|
||||
import android.util.Log;
|
||||
|
||||
// The incoming-call ring + vibration, as ONE looping source tied to the CALL (not the call screen). On Android 14
|
||||
// an UNLOCKED phone shows our call as a heads-up notification WITHOUT launching the full-screen IncomingCallActivity
|
||||
// (full-screen-intent is restricted for non-dialer apps while unlocked), so if the ring lived only in the activity
|
||||
// the phone would ring silently when unlocked. The notification channel is silent (to avoid a double ring), so
|
||||
// this is the single ring source: started from CallStore.showIncoming, stopped from CallStore.cancel (which every
|
||||
// answer/decline/cancel/end path already calls).
|
||||
class Ringer {
|
||||
private static Ringtone ringtone;
|
||||
private static Vibrator vibrator;
|
||||
|
||||
static synchronized void start(Context ctx) {
|
||||
if (ringtone != null) return; // already ringing
|
||||
Context app = ctx.getApplicationContext();
|
||||
try {
|
||||
Uri uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE);
|
||||
if (uri == null) uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION);
|
||||
if (uri != null) {
|
||||
Ringtone r = RingtoneManager.getRingtone(app, uri);
|
||||
if (r != null) {
|
||||
r.setAudioAttributes(new AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build());
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) r.setLooping(true);
|
||||
r.play();
|
||||
ringtone = r;
|
||||
Log.i("bzcall", "ringer started");
|
||||
}
|
||||
}
|
||||
} catch (Exception e) { Log.w("bzcall", "ringer start failed: " + e.getMessage()); }
|
||||
try {
|
||||
Vibrator v = (Vibrator) app.getSystemService(Context.VIBRATOR_SERVICE);
|
||||
if (v != null && v.hasVibrator()) {
|
||||
long[] pattern = {0, 800, 1000};
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
v.vibrate(android.os.VibrationEffect.createWaveform(pattern, 0)); // 0 = loop from start
|
||||
} else {
|
||||
v.vibrate(pattern, 0);
|
||||
}
|
||||
vibrator = v;
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
static synchronized void stop() {
|
||||
try { if (ringtone != null) ringtone.stop(); } catch (Exception ignored) {}
|
||||
ringtone = null;
|
||||
try { if (vibrator != null) vibrator.cancel(); } catch (Exception ignored) {}
|
||||
vibrator = null;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user