diff --git a/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/CallStore.java b/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/CallStore.java index fb994cb..37dec82 100644 --- a/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/CallStore.java +++ b/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/CallStore.java @@ -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) {} } } diff --git a/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/IncomingCallActivity.java b/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/IncomingCallActivity.java index 9cbf20e..633cdcb 100644 --- a/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/IncomingCallActivity.java +++ b/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/IncomingCallActivity.java @@ -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(); }); } } diff --git a/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/Ringer.java b/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/Ringer.java new file mode 100644 index 0000000..118f4fe --- /dev/null +++ b/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/Ringer.java @@ -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; + } +}