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