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) {
|
} catch (SecurityException e) {
|
||||||
Log.w("bzcall", "notify() blocked — POST_NOTIFICATIONS not granted: " + e.getMessage());
|
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) {
|
static void cancel(Context ctx) {
|
||||||
|
Ringer.stop();
|
||||||
try { NotificationManagerCompat.from(ctx).cancel(NOTIF_ID); } catch (Exception ignored) {}
|
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.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.graphics.Color;
|
import android.graphics.Color;
|
||||||
import android.media.Ringtone;
|
|
||||||
import android.media.RingtoneManager;
|
|
||||||
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.util.Log;
|
||||||
@@ -27,8 +24,6 @@ public class IncomingCallActivity extends Activity {
|
|||||||
|
|
||||||
private static IncomingCallActivity current;
|
private static IncomingCallActivity current;
|
||||||
|
|
||||||
private Ringtone ringtone;
|
|
||||||
private android.os.Vibrator vibrator;
|
|
||||||
private JSObject callData;
|
private JSObject callData;
|
||||||
private boolean handled = false;
|
private boolean handled = false;
|
||||||
|
|
||||||
@@ -52,10 +47,10 @@ public class IncomingCallActivity extends Activity {
|
|||||||
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; }
|
||||||
// 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;
|
current = this;
|
||||||
setContentView(buildUi());
|
setContentView(buildUi());
|
||||||
startRinging();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private JSObject extras(Intent intent) {
|
private JSObject extras(Intent intent) {
|
||||||
@@ -188,44 +183,12 @@ public class IncomingCallActivity extends Activity {
|
|||||||
return col;
|
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() {
|
private void doAccept() {
|
||||||
if (handled) { finish(); return; }
|
if (handled) { finish(); return; }
|
||||||
handled = true;
|
handled = true;
|
||||||
stopRinging();
|
|
||||||
String uuid = callData.getString("callUUID", "");
|
String uuid = callData.getString("callUUID", "");
|
||||||
CallStore.markActive(uuid);
|
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
|
// 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).
|
// in the plugin until the WebView JS attaches — covers a cold/locked pickup).
|
||||||
Intent launch = getPackageManager().getLaunchIntentForPackage(getPackageName());
|
Intent launch = getPackageManager().getLaunchIntentForPackage(getPackageName());
|
||||||
@@ -240,9 +203,8 @@ public class IncomingCallActivity extends Activity {
|
|||||||
private void doDecline() {
|
private void doDecline() {
|
||||||
if (handled) { finish(); return; }
|
if (handled) { finish(); return; }
|
||||||
handled = true;
|
handled = true;
|
||||||
stopRinging();
|
|
||||||
CallStore.markEnded(callData.getString("callUUID", ""));
|
CallStore.markEnded(callData.getString("callUUID", ""));
|
||||||
CallStore.cancel(getApplicationContext());
|
CallStore.cancel(getApplicationContext()); // stops the Ringer + clears the notification
|
||||||
NativeCallPlugin.deliverEnd(callData);
|
NativeCallPlugin.deliverEnd(callData);
|
||||||
finishAndClear();
|
finishAndClear();
|
||||||
}
|
}
|
||||||
@@ -254,7 +216,8 @@ public class IncomingCallActivity extends Activity {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onDestroy() {
|
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;
|
if (current == this) current = null;
|
||||||
super.onDestroy();
|
super.onDestroy();
|
||||||
}
|
}
|
||||||
@@ -262,11 +225,10 @@ public class IncomingCallActivity extends Activity {
|
|||||||
private int dp(int v) { return Math.round(getResources().getDisplayMetrics().density * v); }
|
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.
|
// 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() {
|
static void dismissIfShowing() {
|
||||||
final IncomingCallActivity a = current;
|
final IncomingCallActivity a = current;
|
||||||
if (a != null) a.runOnUiThread(() -> {
|
if (a != null) a.runOnUiThread(() -> { if (!a.isFinishing()) a.finish(); });
|
||||||
a.stopRinging();
|
|
||||||
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