From 53a8bda18f1f9d846abd3f127b9a6185916b7888 Mon Sep 17 00:00:00 2001 From: heaven Date: Mon, 20 Apr 2026 22:22:43 +0300 Subject: [PATCH] dm calls mvp: phase 2.5.3: lockscreen CallStyle with FSI, Answer/Decline, ringtone channel, permission prompt --- android/app/capacitor.build.gradle | 1 + android/app/src/main/AndroidManifest.xml | 14 + .../chat/vojo/app/CallCancelReceiver.java | 37 +++ .../chat/vojo/app/FullScreenIntentPlugin.java | 75 +++++ .../main/java/chat/vojo/app/MainActivity.java | 4 + .../app/VojoFirebaseMessagingService.java | 299 +++++++++++++++++- android/capacitor.settings.gradle | 3 + public/locales/en.json | 4 + public/locales/ru.json | 4 + .../FullScreenIntentPrompt.tsx | 143 +++++++++ .../full-screen-intent-prompt/index.ts | 1 + .../useDismissNativeCallNotifications.ts | 60 ++++ src/app/hooks/usePendingCallActionConsumer.ts | 52 +++ src/app/hooks/usePushNotifications.ts | 107 +++++-- src/app/pages/IncomingCallStripRenderer.tsx | 14 +- src/app/pages/Router.tsx | 4 + src/app/pages/client/ClientNonUIFeatures.tsx | 2 + src/app/plugins/fullScreenIntent.ts | 36 +++ src/app/state/pendingCallAction.ts | 11 + 19 files changed, 841 insertions(+), 30 deletions(-) create mode 100644 android/app/src/main/java/chat/vojo/app/CallCancelReceiver.java create mode 100644 android/app/src/main/java/chat/vojo/app/FullScreenIntentPlugin.java create mode 100644 src/app/components/full-screen-intent-prompt/FullScreenIntentPrompt.tsx create mode 100644 src/app/components/full-screen-intent-prompt/index.ts create mode 100644 src/app/hooks/useDismissNativeCallNotifications.ts create mode 100644 src/app/hooks/usePendingCallActionConsumer.ts create mode 100644 src/app/plugins/fullScreenIntent.ts create mode 100644 src/app/state/pendingCallAction.ts diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index bfc8b5fd..ad2ae663 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -11,6 +11,7 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { implementation project(':capacitor-app') implementation project(':capacitor-browser') + implementation project(':capacitor-preferences') implementation project(':capacitor-push-notifications') implementation project(':capacitor-toast') diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index bcbfb4df..9a1bcd77 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -49,10 +49,24 @@ + + + + + + + diff --git a/android/app/src/main/java/chat/vojo/app/CallCancelReceiver.java b/android/app/src/main/java/chat/vojo/app/CallCancelReceiver.java new file mode 100644 index 00000000..aa9d0bda --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/CallCancelReceiver.java @@ -0,0 +1,37 @@ +package chat.vojo.app; + +import android.app.NotificationManager; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +/** + * Dismisses the incoming-call notification when its RTC lifetime expires. + * + * Scheduled by {@link VojoFirebaseMessagingService} via AlarmManager at + * sender_ts + lifetime. The extras carry tag/id so we can target the exact + * notification even if multiple ring pushes overlap (rare but possible across + * rapid re-dials). + * + * Also invoked directly (same intent shape) when the app receives a decline / + * other-device-answer via the live Matrix sync and wants the system notification + * cleared — see Capacitor removeDeliveredNotifications in the JS layer. + */ +public class CallCancelReceiver extends BroadcastReceiver { + + public static final String ACTION_CANCEL_CALL = "chat.vojo.app.CANCEL_CALL"; + public static final String EXTRA_NOTIF_TAG = "notif_tag"; + public static final String EXTRA_NOTIF_ID = "notif_id"; + + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null || !ACTION_CANCEL_CALL.equals(intent.getAction())) return; + String tag = intent.getStringExtra(EXTRA_NOTIF_TAG); + int id = intent.getIntExtra(EXTRA_NOTIF_ID, -1); + if (tag == null || id == -1) return; + NotificationManager nm = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + if (nm == null) return; + nm.cancel(tag, id); + } +} diff --git a/android/app/src/main/java/chat/vojo/app/FullScreenIntentPlugin.java b/android/app/src/main/java/chat/vojo/app/FullScreenIntentPlugin.java new file mode 100644 index 00000000..ec3d9136 --- /dev/null +++ b/android/app/src/main/java/chat/vojo/app/FullScreenIntentPlugin.java @@ -0,0 +1,75 @@ +package chat.vojo.app; + +import android.app.NotificationManager; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.provider.Settings; + +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; + +/** + * Bridges Android 14+ (API 34) full-screen-intent opt-in into JS. + * + * On API 34 `USE_FULL_SCREEN_INTENT` was reclassified from "normal" to + * "special appop" — declaring it in the manifest is no longer enough to + * actually display a full-screen notification over the lockscreen. The user + * must opt in via Settings → Apps → Vojo → Full-screen notifications. There's + * no runtime grant API, only a deep-link. + * + * Without the opt-in, `setFullScreenIntent(launchPI, true)` still satisfies + * the AOSP NotificationManagerService gate (so CallStyle doesn't get silently + * dropped), but the notification renders as a regular heads-up and the screen + * doesn't wake over the lockscreen — which was the "why is this just a banner + * on the lockscreen?" symptom we saw on the Samsung OneUI test device. + * + * See docs/plans/dm_calls.md ADR 2.5-fsi for the full history. + */ +@CapacitorPlugin(name = "FullScreenIntent") +public class FullScreenIntentPlugin extends Plugin { + + @PluginMethod + public void canUseFullScreenIntent(PluginCall call) { + JSObject ret = new JSObject(); + ret.put("value", canUseFullScreenIntentInternal()); + call.resolve(ret); + } + + @PluginMethod + public void openSettings(PluginCall call) { + Context ctx = getContext(); + Intent intent; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + // API 34+ has a dedicated Settings screen for the full-screen notification opt-in. + intent = new Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT); + intent.setData(Uri.parse("package:" + ctx.getPackageName())); + } else { + // Fallback for API ≤33: the per-app notification Settings page is the closest + // equivalent and also covers channel-level toggles (mute, DND bypass, etc). + intent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS); + intent.putExtra(Settings.EXTRA_APP_PACKAGE, ctx.getPackageName()); + } + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + try { + ctx.startActivity(intent); + call.resolve(); + } catch (Throwable t) { + call.reject("Failed to open FSI settings: " + t.getMessage()); + } + } + + private boolean canUseFullScreenIntentInternal() { + // On API ≤33 `USE_FULL_SCREEN_INTENT` is a normal permission — if it's + // declared in the manifest, the app already has it. Skip the runtime check. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return true; + NotificationManager nm = (NotificationManager) + getContext().getSystemService(Context.NOTIFICATION_SERVICE); + if (nm == null) return false; + return nm.canUseFullScreenIntent(); + } +} diff --git a/android/app/src/main/java/chat/vojo/app/MainActivity.java b/android/app/src/main/java/chat/vojo/app/MainActivity.java index 21d14028..11ecdb35 100644 --- a/android/app/src/main/java/chat/vojo/app/MainActivity.java +++ b/android/app/src/main/java/chat/vojo/app/MainActivity.java @@ -9,6 +9,10 @@ public class MainActivity extends BridgeActivity { @Override protected void onCreate(Bundle savedInstanceState) { + // Custom plugins must be registered before super.onCreate so BridgeActivity + // can wire them into the WebView bridge on load. Registering after + // super.onCreate would make the plugin invisible to JS until the next relaunch. + registerPlugin(FullScreenIntentPlugin.class); EdgeToEdge.enable(this); super.onCreate(savedInstanceState); } diff --git a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java index 6f728c09..21895eeb 100644 --- a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java +++ b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java @@ -1,12 +1,19 @@ package chat.vojo.app; +import android.app.AlarmManager; +import android.app.NotificationChannel; 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.Person; import com.capacitorjs.plugins.pushnotifications.MessagingService; import com.google.firebase.messaging.RemoteMessage; @@ -19,27 +26,68 @@ import java.util.Map; * base MessagingService just forwards to JS via `pushNotificationReceived`, * but JS listeners detach when the WebView is paused/backgrounded. * - * This service builds a system notification whenever the activity is NOT in - * the foreground — covering both the "backgrounded" and "killed" cases. - * When the activity IS visible, in-app MessageNotifications handles display. + * Message branch: builds a system notification when the activity is NOT in + * the foreground — covering both "backgrounded" and "killed" cases. * - * Each message gets its own notification (unique id per event_id). Android - * auto-groups them under the "vojo_messages" group when there are 4+. + * Call branch: for `org.matrix.msc4075.rtc.notification` + `notification_type=ring` + * we always show a CallStyle incoming-call notification (independent of + * foreground state) with Answer/Decline actions + full-screen intent that + * wakes the device and launches MainActivity over the lockscreen — the + * WhatsApp/Telegram incoming-call UX. The FSI is also what satisfies AOSP + * NotificationManagerService.checkDisqualifyingFeatures on API 31+; without + * it CallStyle throws IAE and the notification is silently dropped. See + * docs/plans/dm_calls.md ADR 2.5-fsi. */ public class VojoFirebaseMessagingService extends MessagingService { private static final String CHANNEL_ID = "vojo_messages"; private static final String GROUP_KEY = "vojo_messages"; + // NotificationChannel settings (vibration pattern, sound, importance) are + // immutable after creation on API 26+. Bump this ID whenever the channel + // config changes so upgrades actually pick up the new settings instead of + // silently using the stale channel the user already has. v2 = 20-pulse + // ring vibration + ringtone sound (previously ~2 pulses, silent tail). + private static final String CALL_CHANNEL_ID = "vojo_calls_v2"; + private static final String LEGACY_CALL_CHANNEL_ID = "vojo_calls"; // Reserved id for the group summary. Chosen as MIN_VALUE so it can't collide // with String.hashCode() of any event/room key (which notoriously returns 0 // for the empty string and a handful of other inputs). private static final int SUMMARY_NOTIFICATION_ID = Integer.MIN_VALUE; + private static final String RTC_NOTIFICATION_TYPE = "org.matrix.msc4075.rtc.notification"; + private static final long RTC_DEFAULT_LIFETIME_MS = 30_000L; + private static final long RTC_LIFETIME_GRACE_MS = 2_000L; + + private static final String TAG = "VojoFCM"; + @Override public void onMessageReceived(RemoteMessage remoteMessage) { super.onMessageReceived(remoteMessage); - if (!MainActivity.isInForeground) { - showSystemNotification(remoteMessage); + Map data = remoteMessage.getData(); + Log.d(TAG, "recv: type=" + data.get("type") + + " cn_type=" + data.get("content_notification_type") + + " room=" + data.get("room_id") + + " event=" + data.get("event_id") + + " fg=" + MainActivity.isInForeground); + try { + if (RTC_NOTIFICATION_TYPE.equals(data.get("type")) + && "ring".equals(data.get("content_notification_type"))) { + Log.d(TAG, "route: call-branch"); + showIncomingCallNotification(remoteMessage); + return; + } + if (!MainActivity.isInForeground) { + Log.d(TAG, "route: message-branch (background)"); + showSystemNotification(remoteMessage); + } else { + Log.d(TAG, "route: skip (foreground, non-call)"); + } + } catch (Throwable t) { + // Don't let any notification-construction bug crash the FCM service — if we + // do, Android restarts the process for the next push (PID churn visible in + // logcat) and eventually shows the "app keeps stopping" dialog to the user. + // Better to swallow + log and lose one notification than kill the process. + Log.e(TAG, "onMessageReceived: uncaught exception in notification flow", t); } } @@ -88,13 +136,24 @@ public class VojoFirebaseMessagingService extends MessagingService { .setCategory(NotificationCompat.CATEGORY_MESSAGE); NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - if (nm == null) return; + if (nm == null) { + Log.w(TAG, "msg: NotificationManager is null, abort"); + return; + } + + ensureMessageChannel(nm); // Unique notification id per event — each message shows separately in the shade. // Guard against the (rare) hashCode collision with the reserved summary id. int notifId = uniqueKey.hashCode(); if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1; - nm.notify(notifId, builder.build()); + Log.d(TAG, "msg: posting notif id=" + notifId + " channel=" + CHANNEL_ID + + " notifsEnabled=" + nm.areNotificationsEnabled()); + try { + nm.notify(notifId, builder.build()); + } catch (SecurityException e) { + Log.e(TAG, "msg: nm.notify threw SecurityException", e); + } // Summary notification for the group (Android shows this when 4+ notifications stack) NotificationCompat.Builder summary = new NotificationCompat.Builder(this, CHANNEL_ID) @@ -107,6 +166,228 @@ public class VojoFirebaseMessagingService extends MessagingService { nm.notify(SUMMARY_NOTIFICATION_ID, summary.build()); } + private void showIncomingCallNotification(RemoteMessage message) { + Map data = message.getData(); + String roomId = data.get("room_id"); + String notifEventId = data.get("event_id"); + if (roomId == null || notifEventId == null) { + Log.w(TAG, "call: missing roomId/eventId, abort"); + return; + } + + NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (nm == null) { + Log.w(TAG, "call: NotificationManager is null, abort"); + return; + } + + ensureCallChannel(nm); + + String callerName = firstNonEmpty( + data.get("sender_display_name"), + data.get("room_name"), + data.get("sender"), + "Vojo" + ); + String messageId = message.getMessageId(); + String tag = "call_" + roomId; + int notifId = tag.hashCode(); + if (notifId == SUMMARY_NOTIFICATION_ID) notifId += 1; + + // Different request codes per action — FLAG_UPDATE_CURRENT would otherwise + // rewrite the previously-stored extras across answer/decline/launch intents. + int answerReq = ("ans_" + notifEventId).hashCode(); + int declineReq = ("dec_" + notifEventId).hashCode(); + int launchReq = ("open_" + notifEventId).hashCode(); + + PendingIntent answerPI = buildActionPI( + answerReq, "answer", roomId, notifEventId, messageId + ); + PendingIntent declinePI = buildActionPI( + declineReq, "decline", roomId, notifEventId, messageId + ); + PendingIntent launchPI = buildActionPI( + launchReq, null, roomId, notifEventId, messageId + ); + + Person caller = new Person.Builder().setName(callerName).build(); + + Uri ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CALL_CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setOngoing(true) + .setAutoCancel(false) + .setContentIntent(launchPI) + // Full-screen wakeup over lockscreen — the "real" incoming-call UX + // (WhatsApp/Telegram-style). Also the only reliable way to satisfy + // AOSP's checkDisqualifyingFeatures gate for CallStyle on API 31+; + // Samsung OneUI specifically rejects CallStyle with `fgs=false` + + // `FullScreenIntent=null` even when USE_FULL_SCREEN_INTENT is just + // declared. Requires the permission in the manifest. + .setFullScreenIntent(launchPI, true) + .setStyle(NotificationCompat.CallStyle.forIncomingCall(caller, declinePI, answerPI)); + + // Builder-level sound/vibration are ignored on API 26+ once the + // channel has its own settings — we configure both on the channel in + // ensureCallChannel() and skip the redundant builder calls here. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + if (ringtoneUri != null) builder.setSound(ringtoneUri); + builder.setVibrate(buildRingVibrationPattern()); + } + + Log.d(TAG, "call: posting notif tag=" + tag + " id=" + notifId + + " channel=" + CALL_CHANNEL_ID + " notifsEnabled=" + nm.areNotificationsEnabled()); + try { + nm.notify(tag, notifId, builder.build()); + Log.d(TAG, "call: nm.notify returned OK"); + } catch (Throwable t) { + Log.e(TAG, "call: nm.notify threw", t); + return; + } + + try { + scheduleCallNotificationExpiry(data, tag, notifId); + } catch (Throwable t) { + Log.e(TAG, "call: scheduleCallNotificationExpiry threw", t); + } + } + + private PendingIntent buildActionPI( + int requestCode, + String callAction, + String roomId, + String notifEventId, + String messageId + ) { + Intent intent = new Intent(this, MainActivity.class) + .setAction(Intent.ACTION_VIEW) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); + // Capacitor PushNotificationsPlugin gates `pushNotificationActionPerformed` + // on bundle.containsKey("google.message_id") — without it the JS listener + // silently never fires and the pendingCallActionAtom stays unset. + intent.putExtra("google.message_id", messageId != null ? messageId : ""); + intent.putExtra("room_id", roomId); + intent.putExtra("notif_event_id", notifEventId); + if (callAction != null) intent.putExtra("call_action", callAction); + int flags = PendingIntent.FLAG_UPDATE_CURRENT + | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); + return PendingIntent.getActivity(this, requestCode, intent, flags); + } + + // Mirrors the JS-side createChannel in usePushNotifications.ts. Lazy creation + // from the service covers the fresh-install + killed-process race: FCM may + // deliver before the app has ever been launched (so the JS lifecycle effect + // never ran), in which case the channel doesn't exist yet and nm.notify + // would silently drop. + private void ensureMessageChannel(NotificationManager nm) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; + if (nm.getNotificationChannel(CHANNEL_ID) != null) return; + Log.d(TAG, "msg: creating channel " + CHANNEL_ID); + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + "Messages", + NotificationManager.IMPORTANCE_HIGH + ); + channel.setDescription("New chat messages and invites"); + channel.enableVibration(true); + channel.enableLights(true); + nm.createNotificationChannel(channel); + } + + private void ensureCallChannel(NotificationManager nm) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; + if (nm.getNotificationChannel(CALL_CHANNEL_ID) != null) return; + // Drop the pre-v2 channel on first creation of v2 so it doesn't linger + // in Settings → Notifications (user-visible cruft) after the bump. + if (nm.getNotificationChannel(LEGACY_CALL_CHANNEL_ID) != null) { + Log.d(TAG, "call: deleting legacy channel " + LEGACY_CALL_CHANNEL_ID); + nm.deleteNotificationChannel(LEGACY_CALL_CHANNEL_ID); + } + Log.d(TAG, "call: creating channel " + CALL_CHANNEL_ID); + NotificationChannel channel = new NotificationChannel( + CALL_CHANNEL_ID, + "Incoming calls", + NotificationManager.IMPORTANCE_HIGH + ); + channel.setDescription("Incoming Vojo calls"); + channel.setBypassDnd(true); + channel.setLockscreenVisibility(NotificationCompat.VISIBILITY_PUBLIC); + channel.enableVibration(true); + // Channel patterns don't auto-repeat on API 26+ (Android won't loop a + // NotificationChannel vibrationPattern the way Vibrator.vibrate(repeat=0) + // would). We pre-expand the pulse sequence to ~30s of alternating + // on/off so the device keeps vibrating for the full ring lifetime + // instead of buzzing twice and going silent. + channel.setVibrationPattern(buildRingVibrationPattern()); + Uri ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE); + if (ringtoneUri != null) { + AudioAttributes attrs = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build(); + channel.setSound(ringtoneUri, attrs); + } + nm.createNotificationChannel(channel); + } + + private void scheduleCallNotificationExpiry( + Map data, + String tag, + int notifId + ) { + long senderTs = parseLong(data.get("content_sender_ts"), -1L); + long lifetime = parseLong(data.get("content_lifetime"), RTC_DEFAULT_LIFETIME_MS); + long baseTs = (senderTs > 0) ? senderTs : System.currentTimeMillis(); + long triggerAt = baseTs + lifetime + RTC_LIFETIME_GRACE_MS; + + AlarmManager am = (AlarmManager) getSystemService(Context.ALARM_SERVICE); + if (am == null) return; + + Intent cancelIntent = new Intent(this, CallCancelReceiver.class) + .setAction(CallCancelReceiver.ACTION_CANCEL_CALL) + .putExtra(CallCancelReceiver.EXTRA_NOTIF_TAG, tag) + .putExtra(CallCancelReceiver.EXTRA_NOTIF_ID, notifId); + int flags = PendingIntent.FLAG_UPDATE_CURRENT + | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); + PendingIntent pi = PendingIntent.getBroadcast(this, notifId, cancelIntent, flags); + + // setAndAllowWhileIdle is enough for a 30s-ish dismiss: exactness is + // nice-to-have but not worth the SCHEDULE_EXACT_ALARM user grant on API 34+. + // Drift of a few minutes on doze-deep devices is acceptable — the JS layer + // cancels proactively on decline / self-join anyway. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAt, pi); + } else { + am.set(AlarmManager.RTC_WAKEUP, triggerAt, pi); + } + } + + // ~30 seconds of alternating 1000ms on / 500ms off — long enough to cover + // the RTC_DEFAULT_LIFETIME_MS ring window. Channels can't repeat patterns, + // so the sequence is pre-expanded. + private static long[] buildRingVibrationPattern() { + final int pulses = 20; + long[] pattern = new long[pulses * 2 + 1]; + pattern[0] = 0; + for (int i = 0; i < pulses; i++) { + pattern[i * 2 + 1] = 1000L; + pattern[i * 2 + 2] = 500L; + } + return pattern; + } + + private static long parseLong(String s, long fallback) { + if (s == null) return fallback; + try { + return Long.parseLong(s); + } catch (NumberFormatException e) { + return fallback; + } + } + private static String firstNonEmpty(String... values) { for (String v : values) { if (v != null && !v.isEmpty()) return v; diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index 94394d66..98d8523d 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -8,6 +8,9 @@ project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/ include ':capacitor-browser' project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android') +include ':capacitor-preferences' +project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android') + include ':capacitor-push-notifications' project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android') diff --git a/public/locales/en.json b/public/locales/en.json index d0d4cde3..1b5b7ba1 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -173,6 +173,10 @@ "push_prompt_title": "Enable notifications", "push_prompt_body": "Get new messages even when Vojo is closed. You can change this anytime in Settings.", "push_prompt_later": "Not now", + "fsi_prompt_title": "Incoming-call screen", + "fsi_prompt_body": "Let Vojo show full-screen notifications so incoming calls wake the screen and appear over the lockscreen, like WhatsApp or Telegram. Open \"Full-screen notifications\" and turn Vojo on.", + "fsi_prompt_later": "Not now", + "fsi_prompt_open": "Open settings", "email_notification": "Email Notification", "email_no_email": "Your account does not have any email attached.", "email_send_notif": "Send notification to your email.", diff --git a/public/locales/ru.json b/public/locales/ru.json index b6e08d25..fc6d943a 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -173,6 +173,10 @@ "push_prompt_title": "Включить уведомления", "push_prompt_body": "Получайте новые сообщения, даже когда Vojo закрыт. Вы можете изменить это в настройках.", "push_prompt_later": "Позже", + "fsi_prompt_title": "Экран входящего звонка", + "fsi_prompt_body": "Разрешите Vojo показывать полноэкранные уведомления — тогда входящие звонки будут будить экран и появляться поверх блокировки, как в WhatsApp или Telegram. Откройте «Уведомления поверх экрана блокировки» и включите переключатель для Vojo.", + "fsi_prompt_later": "Позже", + "fsi_prompt_open": "Открыть настройки", "email_notification": "Уведомления по почте", "email_no_email": "К вашему аккаунту не привязана электронная почта.", "email_send_notif": "Отправлять уведомления на вашу почту.", diff --git a/src/app/components/full-screen-intent-prompt/FullScreenIntentPrompt.tsx b/src/app/components/full-screen-intent-prompt/FullScreenIntentPrompt.tsx new file mode 100644 index 00000000..7d1ecd8b --- /dev/null +++ b/src/app/components/full-screen-intent-prompt/FullScreenIntentPrompt.tsx @@ -0,0 +1,143 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Dialog, + Overlay, + OverlayCenter, + OverlayBackdrop, + Header, + config, + Box, + Text, + IconButton, + Icon, + Icons, + Button, +} from 'folds'; +import { useTranslation } from 'react-i18next'; +import { isNativePlatform } from '../../utils/capacitor'; +import { + canUseFullScreenIntent, + openFullScreenIntentSettings, +} from '../../plugins/fullScreenIntent'; +import { usePushEnabled } from '../../hooks/usePushNotifications'; +import { stopPropagation } from '../../utils/keyboard'; + +// Mirrors PushPermissionPrompt's cooldown. 7 days is long enough that "not now" +// isn't nagging, short enough that a user who changes their mind doesn't have +// to dig through Settings to find the toggle. +const DISMISS_KEY = 'vojo_fsi_prompt_dismissed_at'; +const DISMISS_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000; +const INITIAL_DELAY_MS = 1500; + +const wasRecentlyDismissed = (): boolean => { + const raw = localStorage.getItem(DISMISS_KEY); + if (!raw) return false; + const ts = Number(raw); + if (!Number.isFinite(ts)) return false; + return Date.now() - ts < DISMISS_COOLDOWN_MS; +}; + +const markDismissed = (): void => { + localStorage.setItem(DISMISS_KEY, String(Date.now())); +}; + +export function FullScreenIntentPrompt() { + const { t } = useTranslation(); + const pushEnabled = usePushEnabled(); + const [visible, setVisible] = useState(false); + + useEffect(() => { + // Only relevant when push is on — FSI wakeup only matters if we're actually + // going to show CallStyle notifications. Showing this before push is enabled + // would be out-of-context: user doesn't yet know what "incoming call screen" + // means in our UX. The PushPermissionPrompt comes first. + if (!isNativePlatform()) return undefined; + if (!pushEnabled) { + setVisible(false); + return undefined; + } + if (wasRecentlyDismissed()) return undefined; + + let cancelled = false; + canUseFullScreenIntent().then((allowed) => { + if (cancelled) return; + if (allowed) return; + // Delay matches PushPermissionPrompt so the two never try to stack on + // top of each other at exact startup — PushPermissionPrompt goes first + // since its effect depends on `status === 'prompt'`; by the time that's + // resolved to 'granted' (and pushEnabled flips true), the PushPrompt is + // already gone and this one has room. + setTimeout(() => { + if (!cancelled) setVisible(true); + }, INITIAL_DELAY_MS); + }); + + return () => { + cancelled = true; + }; + }, [pushEnabled]); + + const handleLater = useCallback(() => { + markDismissed(); + setVisible(false); + }, []); + + const handleEnable = useCallback(() => { + // Opens Settings; the user returns to the app via the back button. We don't + // re-check canUseFullScreenIntent() on resume because the prompt will + // simply not re-show (7-day cooldown would apply if we markDismissed — + // we deliberately DO NOT mark dismissed here so if the user declines in + // Settings or forgets, the next startup still reminds them). + openFullScreenIntentSettings().catch(() => { + /* plugin missing — nothing to do */ + }); + setVisible(false); + }, []); + + if (!visible) return null; + + return ( + }> + + + +
+ + {t('Settings.fsi_prompt_title')} + + + + +
+ + {t('Settings.fsi_prompt_body')} + + + + + +
+
+
+
+ ); +} diff --git a/src/app/components/full-screen-intent-prompt/index.ts b/src/app/components/full-screen-intent-prompt/index.ts new file mode 100644 index 00000000..4d52e35d --- /dev/null +++ b/src/app/components/full-screen-intent-prompt/index.ts @@ -0,0 +1 @@ +export * from './FullScreenIntentPrompt'; diff --git a/src/app/hooks/useDismissNativeCallNotifications.ts b/src/app/hooks/useDismissNativeCallNotifications.ts new file mode 100644 index 00000000..4c514f50 --- /dev/null +++ b/src/app/hooks/useDismissNativeCallNotifications.ts @@ -0,0 +1,60 @@ +import { useEffect, useRef } from 'react'; +import { useAtomValue } from 'jotai'; +import { incomingCallsAtom } from '../state/incomingCalls'; +import { isNativePlatform } from '../utils/capacitor'; + +// When the in-app incomingCallsAtom drops an entry (user accepted, declined, +// other device joined, lifetime expired), also clear the CallStyle notification +// the native service posted for that room. The AlarmManager fallback in the +// Java service handles killed-process dismiss on lifetime expiry; this hook +// covers the live-client paths where JS knows the truth sooner than the alarm. +export const useDismissNativeCallNotifications = (): void => { + const incoming = useAtomValue(incomingCallsAtom); + const prevRoomsRef = useRef>(new Set()); + + useEffect(() => { + const nextRooms = new Set(); + incoming.forEach((call) => nextRooms.add(call.roomId)); + + const dropped: string[] = []; + prevRoomsRef.current.forEach((roomId) => { + if (!nextRooms.has(roomId)) dropped.push(roomId); + }); + prevRoomsRef.current = nextRooms; + + if (dropped.length === 0) return; + if (!isNativePlatform()) return; + + (async () => { + const { PushNotifications } = await import('@capacitor/push-notifications'); + // Shape mirrors VojoFirebaseMessagingService.showIncomingCallNotification: + // tag = "call_" + roomId, id = tag.hashCode(). The Android Capacitor + // plugin reads id via JSObject.getInteger (PushNotificationsPlugin.java + // line 166), so the id must go over the bridge as a number — the TS + // type declares string, hence the cast. + const notifications = dropped.map((roomId) => { + const tag = `call_${roomId}`; + return { id: javaStringHashCode(tag), tag }; + }); + await PushNotifications.removeDeliveredNotifications({ + notifications: notifications as unknown as Parameters< + typeof PushNotifications.removeDeliveredNotifications + >[0]['notifications'], + }).catch(() => { + /* best-effort — alarm fallback still dismisses at lifetime expiry */ + }); + })(); + }, [incoming]); +}; + +// Reproduces java.lang.String#hashCode so JS-side ids match the tag ids the +// Java service computed. Must stay in sync with CallCancelReceiver / the +// tag.hashCode() call in VojoFirebaseMessagingService. +function javaStringHashCode(s: string): number { + let h = 0; + for (let i = 0; i < s.length; i += 1) { + // eslint-disable-next-line no-bitwise + h = (Math.imul(31, h) + s.charCodeAt(i)) | 0; + } + return h; +} diff --git a/src/app/hooks/usePendingCallActionConsumer.ts b/src/app/hooks/usePendingCallActionConsumer.ts new file mode 100644 index 00000000..b734cf84 --- /dev/null +++ b/src/app/hooks/usePendingCallActionConsumer.ts @@ -0,0 +1,52 @@ +import { useEffect } from 'react'; +import { useAtomValue, useSetAtom } from 'jotai'; +import { App } from '@capacitor/app'; +import { pendingCallActionAtom } from '../state/pendingCallAction'; +import { incomingCallsAtom } from '../state/incomingCalls'; +import { useDmCallStart } from './useDmCallStart'; +import { useMatrixClient } from './useMatrixClient'; +import { isNativePlatform } from '../utils/capacitor'; + +// Consumes pending call actions emitted by the native Android push-action +// listener (see usePushNotifications.ts). Must be mounted inside CallEmbedProvider +// so useDmCallStart can reach the embed atom. +export const usePendingCallActionConsumer = (): void => { + const pending = useAtomValue(pendingCallActionAtom); + const setPending = useSetAtom(pendingCallActionAtom); + const setIncoming = useSetAtom(incomingCallsAtom); + const startDmCall = useDmCallStart(); + const mx = useMatrixClient(); + + useEffect(() => { + if (!pending) return; + if (pending.kind === 'answer') { + // In-app strip (if any) is stale after accept — drop it so the overlay + // can own the UX without a parallel decline button lingering. + setIncoming({ type: 'REMOVE_BY_ROOM', roomId: pending.roomId }); + startDmCall(pending.roomId); + } else { + setIncoming({ type: 'REMOVE_BY_NOTIF_ID', notifEventId: pending.notifEventId }); + // Fire-and-minimize: dispatch the decline then minimize the app once the + // request settles (success OR failure). Minimizing before sendRtcDecline + // resolves risks the WebView getting paused mid-request on slower devices; + // waiting for settlement gives the network call the tick it needs. + // Lockscreen flow still requires unlock — known MVP compromise; a proper + // BroadcastReceiver-based decline is tracked in techdebt 5.33. + const minimize = () => { + if (!isNativePlatform()) return; + App.minimizeApp().catch(() => { + /* minimize not supported / already in background */ + }); + }; + mx.sendRtcDecline(pending.roomId, pending.notifEventId).then( + () => minimize(), + (err: unknown) => { + // eslint-disable-next-line no-console + console.warn('[call] sendRtcDecline (from push action) failed', err); + minimize(); + } + ); + } + setPending(undefined); + }, [pending, setPending, setIncoming, startDmCall, mx]); +}; diff --git a/src/app/hooks/usePushNotifications.ts b/src/app/hooks/usePushNotifications.ts index a99bd2ef..75f3334e 100644 --- a/src/app/hooks/usePushNotifications.ts +++ b/src/app/hooks/usePushNotifications.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useSetAtom } from 'jotai'; import { useMatrixClient } from './useMatrixClient'; import { useClientConfig } from './useClientConfig'; import { isNativePlatform } from '../utils/capacitor'; @@ -19,7 +20,8 @@ import { unregisterPusher, urlBase64ToUint8Array, } from '../utils/push'; -import { getHomeRoomPath, getInboxInvitesPath } from '../pages/pathUtils'; +import { getDirectRoomPath, getHomeRoomPath, getInboxInvitesPath } from '../pages/pathUtils'; +import { pendingCallActionAtom } from '../state/pendingCallAction'; const noop = (): void => undefined; @@ -272,6 +274,7 @@ export function usePushNotificationsLifecycle(): void { const mx = useMatrixClient(); const clientConfig = useClientConfig(); const navigate = useNavigate(); + const setPendingCallAction = useSetAtom(pendingCallActionAtom); useEffect(() => { if (isNativePlatform()) return; @@ -314,10 +317,72 @@ export function usePushNotificationsLifecycle(): void { useEffect(() => { if (!isNativePlatform()) return undefined; - let cleanup: (() => void) | undefined; + let cancelled = false; + const cleanups: Array<() => void> = []; + const cfg = clientConfig.push; + + const pnPromise = import('@capacitor/push-notifications'); + + // Attach the action listener on its own promise chain — no other awaits + // before it — so cold-start Decline/Answer events (fired by the native + // PushNotifications plugin via handleOnNewIntent the moment MainActivity + // boots) have a listener waiting. Sequential awaits behind channel + // creation / register() added ~100-300ms during which the event could be + // dropped entirely on a killed-process launch. + pnPromise + .then(({ PushNotifications }) => { + if (cancelled) return null; + return PushNotifications.addListener( + 'pushNotificationActionPerformed', + (action) => { + const data = action.notification.data as { + room_id?: string; + call_action?: 'answer' | 'decline'; + notif_event_id?: string; + }; + + // Native CallStyle Answer → open the room and queue an auto-join via + // pendingCallActionAtom. The consumer hook picks it up once the + // CallEmbedProvider tree is mounted. DM rooms live in the Direct tab + // route; `getHomeRoomPath` resolves to a Home-tab placeholder for IDs + // it doesn't have in its left-rail, hence the DM path here. + if (data.call_action === 'answer' && data.room_id) { + navigate(getDirectRoomPath(data.room_id)); + setPendingCallAction({ kind: 'answer', roomId: data.room_id }); + return; + } + + // Decline: queue the action and let the consumer (inside + // CallEmbedProvider) fire sendRtcDecline and then minimize the app. + // Doing the minimize here would race the setAtom — setAtom schedules + // a render-tick, minimize is synchronous, so we'd close the WebView + // before the consumer could pick up the atom and send the decline. + if (data.call_action === 'decline' && data.room_id && data.notif_event_id) { + setPendingCallAction({ + kind: 'decline', + roomId: data.room_id, + notifEventId: data.notif_event_id, + }); + return; + } + + if (data.room_id) navigate(getHomeRoomPath(data.room_id)); + } + ); + }) + .then((h) => { + if (!h) return; + cleanups.push(() => { + h.remove().catch(noop); + }); + }) + .catch(noop); + + // Channel + registration listener + token-kickoff on a parallel chain — + // order doesn't matter for these, they just need to happen eventually. (async () => { - const { PushNotifications } = await import('@capacitor/push-notifications'); - const cfg = clientConfig.push; + const { PushNotifications } = await pnPromise; + if (cancelled) return; // Android 8+ requires a notification channel before any system notification can appear, // and apps with no channels aren't listed in Settings → Notifications. Sygnal sends @@ -338,6 +403,13 @@ export function usePushNotificationsLifecycle(): void { /* channel may already exist */ } + // The call channel (vojo_calls_v2) is created lazily from the native + // VojoFirebaseMessagingService.ensureCallChannel() on first ring. Creating + // it here from JS would race with Java — whichever call wins freezes the + // vibration pattern / sound for the lifetime of the channel (immutable + // after creation on API 26+), and the Capacitor API can't set a long + // repeating vibrationPattern. Let Java own this channel exclusively. + // Persistent listener: update pusher on the server with the (possibly rotated) token. // MUST NOT call the full register() flow here — that would call // PushNotifications.register() again and re-fire this listener → infinite loop. @@ -349,14 +421,13 @@ export function usePushNotificationsLifecycle(): void { .then((ids) => savePusherIds(ids)) .catch(noop); }); - - const actionHandle = await PushNotifications.addListener( - 'pushNotificationActionPerformed', - (action) => { - const roomId = (action.notification.data as { room_id?: string })?.room_id; - if (roomId) navigate(getHomeRoomPath(roomId)); - } - ); + if (cancelled) { + regHandle.remove().catch(noop); + } else { + cleanups.push(() => { + regHandle.remove().catch(noop); + }); + } // Display is handled entirely by the native VojoFirebaseMessagingService // (which fires for both backgrounded and killed process states via FCM's @@ -377,15 +448,13 @@ export function usePushNotificationsLifecycle(): void { /* ignore */ } } - - cleanup = () => { - regHandle.remove(); - actionHandle.remove(); - }; })().catch(noop); - return () => cleanup?.(); - }, [navigate, mx, clientConfig]); + return () => { + cancelled = true; + cleanups.forEach((c) => c()); + }; + }, [navigate, mx, clientConfig, setPendingCallAction]); } export { isPushEnabled, getPushPlatform } from '../utils/push'; diff --git a/src/app/pages/IncomingCallStripRenderer.tsx b/src/app/pages/IncomingCallStripRenderer.tsx index 7cd3e9b0..e8077901 100644 --- a/src/app/pages/IncomingCallStripRenderer.tsx +++ b/src/app/pages/IncomingCallStripRenderer.tsx @@ -3,6 +3,12 @@ // Mounted in Router.tsx inside `CallEmbedProvider`, rendered right before // `CallStatusRenderer` so the strip stacks above the in-call pill. // +// On native Android the OS CallStyle notification (see +// VojoFirebaseMessagingService) owns the incoming-call UX end-to-end: heads-up, +// ringtone, Answer/Decline buttons, full-screen wakeup. Rendering the in-app +// strip in parallel just duplicates UI and plays a second ringtone on top of +// the system one — so the renderer is a no-op on native. +// // KNOWN GAP §5.17: if the browser blocks `audio.play()` (cold page load, no // user gesture yet), the ring is silent — strip is still visible but user may // miss it. Fallback (click-to-enable, pulsing animation, Web Notifications) is @@ -14,6 +20,7 @@ import { Box } from 'folds'; import { incomingCallsAtom } from '../state/incomingCalls'; import { useMatrixClient } from '../hooks/useMatrixClient'; import { IncomingCallStrip } from '../features/call-status'; +import { isNativePlatform } from '../utils/capacitor'; // eslint-disable-next-line import/no-relative-packages import RingSoundOgg from '../../../public/sound/ring.ogg'; // eslint-disable-next-line import/no-relative-packages @@ -25,11 +32,12 @@ export function IncomingCallStripRenderer() { const audioRef = useRef(null); const hasIncoming = incoming.size > 0; + const suppress = isNativePlatform(); useEffect(() => { const audio = audioRef.current; if (!audio) return; - if (hasIncoming) { + if (hasIncoming && !suppress) { audio.currentTime = 0; audio.play().catch(() => { // autoplay blocked — strip UI still visible @@ -38,7 +46,9 @@ export function IncomingCallStripRenderer() { audio.pause(); audio.currentTime = 0; } - }, [hasIncoming]); + }, [hasIncoming, suppress]); + + if (suppress) return null; const entries = Array.from(incoming.values()); diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index c47c06e4..31ef2e98 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -72,11 +72,15 @@ import { CallStatusRenderer } from './CallStatusRenderer'; import { CallEmbedProvider } from '../components/CallEmbedProvider'; import { useIncomingRtcNotifications } from '../hooks/useIncomingRtcNotifications'; import { useCallerAutoHangup } from '../hooks/useCallerAutoHangup'; +import { usePendingCallActionConsumer } from '../hooks/usePendingCallActionConsumer'; +import { useDismissNativeCallNotifications } from '../hooks/useDismissNativeCallNotifications'; import { IncomingCallStripRenderer } from './IncomingCallStripRenderer'; function IncomingCallsFeature() { useIncomingRtcNotifications(); useCallerAutoHangup(); + usePendingCallActionConsumer(); + useDismissNativeCallNotifications(); return null; } diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 07bcc58f..121cdb47 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -20,6 +20,7 @@ import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; import { usePushNotificationsLifecycle } from '../../hooks/usePushNotifications'; import { PushPermissionPrompt } from '../../components/push-permission-prompt'; +import { FullScreenIntentPrompt } from '../../components/full-screen-intent-prompt'; import { useAndroidBackButton } from '../../hooks/useAndroidBackButton'; function SystemEmojiFeature() { @@ -166,6 +167,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + {children} diff --git a/src/app/plugins/fullScreenIntent.ts b/src/app/plugins/fullScreenIntent.ts new file mode 100644 index 00000000..a91ec825 --- /dev/null +++ b/src/app/plugins/fullScreenIntent.ts @@ -0,0 +1,36 @@ +import { registerPlugin } from '@capacitor/core'; +import { isNativePlatform } from '../utils/capacitor'; + +// Bridge to the native FullScreenIntentPlugin (see +// android/app/src/main/java/chat/vojo/app/FullScreenIntentPlugin.java). +// Web and iOS builds get the no-op fallback below — FSI opt-in only exists on Android 14+. +export interface FullScreenIntentPlugin { + canUseFullScreenIntent(): Promise<{ value: boolean }>; + openSettings(): Promise; +} + +const FullScreenIntent = registerPlugin('FullScreenIntent', { + web: { + canUseFullScreenIntent: async () => ({ value: true }), + openSettings: async () => undefined, + }, +}); + +export const canUseFullScreenIntent = async (): Promise => { + if (!isNativePlatform()) return true; + try { + const { value } = await FullScreenIntent.canUseFullScreenIntent(); + return value; + } catch { + // Plugin not yet available (e.g., old build installed before the plugin shipped). + // Fail "allowed" so we don't nag users on older APKs — the permission is a no-op + // on APIs that don't check it, and on APIs that do the ring itself will make the + // problem obvious (no wakeup) and the user can find the setting themselves. + return true; + } +}; + +export const openFullScreenIntentSettings = async (): Promise => { + if (!isNativePlatform()) return; + await FullScreenIntent.openSettings(); +}; diff --git a/src/app/state/pendingCallAction.ts b/src/app/state/pendingCallAction.ts new file mode 100644 index 00000000..e8794111 --- /dev/null +++ b/src/app/state/pendingCallAction.ts @@ -0,0 +1,11 @@ +import { atom } from 'jotai'; + +// Bridge between the native Capacitor push-action listener (which fires outside +// any React render context) and the in-app call flow. The listener sets the +// atom; usePendingCallActionConsumer — mounted inside CallEmbedProvider — reads +// it and triggers useDmCallStart / mx.sendRtcDecline. +export type PendingCallAction = + | { kind: 'answer'; roomId: string } + | { kind: 'decline'; roomId: string; notifEventId: string }; + +export const pendingCallActionAtom = atom(undefined);