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:
SravanandClaude Opus 4.8 committed 2026-10-19 21:36:35 +05:30
1 parent da5b44c0a5
commit 0edbc3f459
3 files changed
+75 -47

No files matched your search

@@ -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) {}
}
}
@@ -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(); });
}
}
@@ -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;
}
}