diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index d69c4483..877fdb80 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -166,6 +166,12 @@
requires the user-granted special app-op, surfaced to the user via
FullScreenIntentPlugin / FullScreenIntentPrompt. -->
+
+
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 ce119bc7..3c943615 100644
--- a/android/app/src/main/java/chat/vojo/app/MainActivity.java
+++ b/android/app/src/main/java/chat/vojo/app/MainActivity.java
@@ -99,6 +99,7 @@ public class MainActivity extends 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);
+ registerPlugin(NotificationPolicyPlugin.class);
registerPlugin(CallForegroundPlugin.class);
// Self-managed Telecom call session (docs/plans/telecom_migration.md) —
// the sole native call backend; owns audio routing (legacy AudioRoute
diff --git a/android/app/src/main/java/chat/vojo/app/NotificationPolicyPlugin.java b/android/app/src/main/java/chat/vojo/app/NotificationPolicyPlugin.java
new file mode 100644
index 00000000..26b5a4cf
--- /dev/null
+++ b/android/app/src/main/java/chat/vojo/app/NotificationPolicyPlugin.java
@@ -0,0 +1,57 @@
+package chat.vojo.app;
+
+import android.app.NotificationManager;
+import android.content.Context;
+import android.content.Intent;
+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 Notification Policy Access ("Do Not Disturb access") into JS so the
+ * incoming-call channel can ring through Do Not Disturb / "Bedtime", like a real
+ * dialer (WhatsApp / Telegram).
+ *
+ * The call channel is created with {@code setBypassDnd(true)}
+ * (VojoFirebaseMessagingService.CALL_CHANNEL_DND_ID), but Android only honours
+ * that if the app holds Notification Policy Access — a user-granted special access
+ * with no runtime grant API, only a Settings deep-link. Without it, an active DND
+ * rule silences the ring and suppresses both the full-screen intent and the
+ * heads-up. DndBypassPrompt surfaces the opt-in; this plugin reports the state and
+ * opens the settings screen. Mirrors {@link FullScreenIntentPlugin}.
+ */
+@CapacitorPlugin(name = "NotificationPolicy")
+public class NotificationPolicyPlugin extends Plugin {
+
+ @PluginMethod
+ public void canBypassDnd(PluginCall call) {
+ JSObject ret = new JSObject();
+ ret.put("value", isPolicyAccessGranted());
+ call.resolve(ret);
+ }
+
+ @PluginMethod
+ public void openSettings(PluginCall call) {
+ Context ctx = getContext();
+ // System-wide "Do Not Disturb access" list — there is no per-app deep link
+ // for this special access; the user finds Vojo in the list and enables it.
+ Intent intent = new Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ try {
+ ctx.startActivity(intent);
+ call.resolve();
+ } catch (Throwable t) {
+ call.reject("Failed to open DND access settings: " + t.getMessage());
+ }
+ }
+
+ private boolean isPolicyAccessGranted() {
+ NotificationManager nm = (NotificationManager)
+ getContext().getSystemService(Context.NOTIFICATION_SERVICE);
+ return nm != null && nm.isNotificationPolicyAccessGranted();
+ }
+}
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 0bfa9696..e4f5551a 100644
--- a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java
+++ b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java
@@ -86,6 +86,13 @@ public class VojoFirebaseMessagingService extends MessagingService {
// 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";
+ // Separate id for the DND-bypassing variant. A channel's canBypassDnd() is
+ // fixed at creation from whether the app held Notification Policy Access then,
+ // and can't be flipped later for the same id (recreating an id inherits the old
+ // tombstoned value). So we keep two ids and pick by access at post time
+ // (resolveCallChannel): this one is created ONLY while access is held, so its
+ // setBypassDnd(true) is actually honored — calls ring through Do Not Disturb.
+ private static final String CALL_CHANNEL_DND_ID = "vojo_calls_dnd";
// 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).
@@ -1911,7 +1918,7 @@ public class VojoFirebaseMessagingService extends MessagingService {
return false;
}
- ensureCallChannel(ctx, nm);
+ String callChannelId = resolveCallChannel(ctx, nm);
String callerName = firstNonEmpty(
data.get("sender_display_name"),
@@ -1944,7 +1951,7 @@ public class VojoFirebaseMessagingService extends MessagingService {
Uri ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE);
- NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, CALL_CHANNEL_ID)
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(ctx, callChannelId)
.setSmallIcon(R.mipmap.ic_launcher)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_HIGH)
@@ -1978,7 +1985,7 @@ public class VojoFirebaseMessagingService extends MessagingService {
}
dlog("call: posting notif tag=" + tag + " id=" + notifId
- + " channel=" + CALL_CHANNEL_ID + " notifsEnabled=" + nm.areNotificationsEnabled()
+ + " channel=" + callChannelId + " notifsEnabled=" + nm.areNotificationsEnabled()
+ " ringEventId=" + ringEventId);
try {
nm.notify(tag, notifId, builder.build());
@@ -2161,23 +2168,47 @@ public class VojoFirebaseMessagingService extends MessagingService {
}
}
- private static void ensureCallChannel(Context ctx, NotificationManager nm) {
+ // Picks which channel to post the incoming-call notification on, by whether
+ // the app currently holds Notification Policy Access. With access → the
+ // DND-bypassing channel (calls ring through Do Not Disturb / "Bedtime", like a
+ // real dialer); without → the plain high-importance channel (today's behaviour,
+ // no regression). The bypass channel is created ONLY while access is held, so
+ // its setBypassDnd(true) is actually honoured.
+ private static String resolveCallChannel(Context ctx, NotificationManager nm) {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return CALL_CHANNEL_ID;
+ if (nm.isNotificationPolicyAccessGranted()) {
+ ensureCallChannel(ctx, nm, CALL_CHANNEL_DND_ID, true);
+ // Keep one "Incoming calls" row in Settings: drop the non-bypass channel
+ // once we've switched to the bypassing one.
+ if (nm.getNotificationChannel(CALL_CHANNEL_ID) != null) {
+ nm.deleteNotificationChannel(CALL_CHANNEL_ID);
+ }
+ return CALL_CHANNEL_DND_ID;
+ }
+ ensureCallChannel(ctx, nm, CALL_CHANNEL_ID, false);
+ return CALL_CHANNEL_ID;
+ }
+
+ private static void ensureCallChannel(
+ Context ctx, NotificationManager nm, String channelId, boolean bypassDnd) {
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
+ if (nm.getNotificationChannel(channelId) != null) return;
+ // Drop the pre-v2 channel on first creation so it doesn't linger
// in Settings → Notifications (user-visible cruft) after the bump.
if (nm.getNotificationChannel(LEGACY_CALL_CHANNEL_ID) != null) {
dlog("call: deleting legacy channel " + LEGACY_CALL_CHANNEL_ID);
nm.deleteNotificationChannel(LEGACY_CALL_CHANNEL_ID);
}
- dlog("call: creating channel " + CALL_CHANNEL_ID);
+ dlog("call: creating channel " + channelId + " bypassDnd=" + bypassDnd);
NotificationChannel channel = new NotificationChannel(
- CALL_CHANNEL_ID,
+ channelId,
"Incoming calls",
NotificationManager.IMPORTANCE_HIGH
);
channel.setDescription("Incoming Vojo calls");
- channel.setBypassDnd(true);
+ // Honoured only if the app holds Notification Policy Access at creation time
+ // (that's why the bypass channel has its own id); otherwise a silent no-op.
+ channel.setBypassDnd(bypassDnd);
channel.setLockscreenVisibility(NotificationCompat.VISIBILITY_PUBLIC);
channel.enableVibration(true);
// Channel patterns don't auto-repeat on API 26+ (Android won't loop a
diff --git a/public/locales/en.json b/public/locales/en.json
index 8c681b38..55631868 100644
--- a/public/locales/en.json
+++ b/public/locales/en.json
@@ -233,6 +233,8 @@
"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",
+ "dnd_prompt_title": "Ring through Do Not Disturb",
+ "dnd_prompt_body": "Let Vojo calls ring even when Do Not Disturb or Bedtime mode is on, like a normal phone call. Open \"Do Not Disturb access\" and turn Vojo on.",
"unexpected_error": "Unexpected Error!",
"all_messages": "All Messages",
"one_to_one": "1-to-1 Chats",
diff --git a/public/locales/ru.json b/public/locales/ru.json
index 399b7960..9ef1e1e5 100644
--- a/public/locales/ru.json
+++ b/public/locales/ru.json
@@ -233,6 +233,8 @@
"fsi_prompt_body": "Разрешите Vojo показывать полноэкранные уведомления — тогда входящие звонки будут будить экран и появляться поверх блокировки, как в WhatsApp или Telegram. Откройте «Уведомления поверх экрана блокировки» и включите переключатель для Vojo.",
"fsi_prompt_later": "Позже",
"fsi_prompt_open": "Открыть настройки",
+ "dnd_prompt_title": "Звонки сквозь «Не беспокоить»",
+ "dnd_prompt_body": "Разрешите звонкам Vojo звонить даже при включённом режиме «Не беспокоить» или «Время сна», как обычный телефонный звонок. Откройте раздел «Доступ к режиму „Не беспокоить“» и включите переключатель для Vojo.",
"unexpected_error": "Непредвиденная ошибка!",
"all_messages": "Все сообщения",
"one_to_one": "Личные чаты",
diff --git a/src/app/components/dnd-bypass-prompt/DndBypassPrompt.tsx b/src/app/components/dnd-bypass-prompt/DndBypassPrompt.tsx
new file mode 100644
index 00000000..9d0bdf2d
--- /dev/null
+++ b/src/app/components/dnd-bypass-prompt/DndBypassPrompt.tsx
@@ -0,0 +1,139 @@
+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 { canBypassDnd, openNotificationPolicySettings } from '../../plugins/notificationPolicy';
+import { canUseFullScreenIntent } from '../../plugins/fullScreenIntent';
+import { usePushEnabled } from '../../hooks/usePushNotifications';
+import { stopPropagation } from '../../utils/keyboard';
+
+// Sibling of FullScreenIntentPrompt: nudges the user to grant "Do Not Disturb
+// access" so incoming calls ring through DND / Bedtime (the call channel already
+// requests setBypassDnd(true); Android only honours it with this access). Same
+// 7-day cooldown so a "Not now" isn't nagging.
+const DISMISS_KEY = 'vojo_dnd_prompt_dismissed_at';
+const DISMISS_COOLDOWN_MS = 7 * 24 * 60 * 60 * 1000;
+// A beat longer than the FSI prompt's 1500ms so the two never stack at startup —
+// this one also self-gates on FSI already being granted (see effect below).
+const INITIAL_DELAY_MS = 2200;
+
+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 DndBypassPrompt() {
+ const { t } = useTranslation();
+ const pushEnabled = usePushEnabled();
+ const [visible, setVisible] = useState(false);
+
+ useEffect(() => {
+ // Only relevant once push is on (we'll actually post call notifications) and,
+ // to avoid stacking, only after the full-screen-intent opt-in is already
+ // handled — FSI is the more fundamental "see the call" grant; this one only
+ // governs whether it also pierces Do Not Disturb.
+ if (!isNativePlatform()) return undefined;
+ if (!pushEnabled) {
+ setVisible(false);
+ return undefined;
+ }
+ if (wasRecentlyDismissed()) return undefined;
+
+ let cancelled = false;
+ Promise.all([canUseFullScreenIntent(), canBypassDnd()]).then(([fsiOk, dndOk]) => {
+ if (cancelled) return;
+ // Defer until FSI is granted (don't double-prompt) and only if calls can't
+ // already ring through DND.
+ if (!fsiOk || dndOk) return;
+ setTimeout(() => {
+ if (!cancelled) setVisible(true);
+ }, INITIAL_DELAY_MS);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [pushEnabled]);
+
+ const handleLater = useCallback(() => {
+ markDismissed();
+ setVisible(false);
+ }, []);
+
+ const handleEnable = useCallback(() => {
+ // Opens the "Do Not Disturb access" list; the user enables Vojo and returns.
+ // We deliberately DON'T markDismissed here, so if they forget, the next
+ // startup reminds them again.
+ openNotificationPolicySettings().catch(() => {
+ /* plugin missing — nothing to do */
+ });
+ setVisible(false);
+ }, []);
+
+ if (!visible) return null;
+
+ return (
+ }>
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/components/dnd-bypass-prompt/index.ts b/src/app/components/dnd-bypass-prompt/index.ts
new file mode 100644
index 00000000..78d15f2f
--- /dev/null
+++ b/src/app/components/dnd-bypass-prompt/index.ts
@@ -0,0 +1 @@
+export * from './DndBypassPrompt';
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx
index 70bd6aa4..b5fdc813 100644
--- a/src/app/pages/client/ClientNonUIFeatures.tsx
+++ b/src/app/pages/client/ClientNonUIFeatures.tsx
@@ -16,6 +16,7 @@ import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { usePushNotificationsLifecycle } from '../../hooks/usePushNotifications';
import { PushPermissionPrompt } from '../../components/push-permission-prompt';
import { FullScreenIntentPrompt } from '../../components/full-screen-intent-prompt';
+import { DndBypassPrompt } from '../../components/dnd-bypass-prompt';
import { useAndroidBackButton } from '../../hooks/useAndroidBackButton';
import { usePendingDeclinesFlusher } from '../../hooks/usePendingDeclinesFlusher';
@@ -168,6 +169,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
+
{children}
diff --git a/src/app/plugins/notificationPolicy.ts b/src/app/plugins/notificationPolicy.ts
new file mode 100644
index 00000000..57120da6
--- /dev/null
+++ b/src/app/plugins/notificationPolicy.ts
@@ -0,0 +1,37 @@
+import { registerPlugin } from '@capacitor/core';
+import { isNativePlatform } from '../utils/capacitor';
+
+// Bridge to the native NotificationPolicyPlugin (see
+// android/app/src/main/java/chat/vojo/app/NotificationPolicyPlugin.java).
+// "Do Not Disturb access" lets the incoming-call channel bypass DND / Bedtime so
+// calls ring through. Web and iOS get the no-op fallback below — this only exists
+// on Android.
+export interface NotificationPolicyPlugin {
+ canBypassDnd(): Promise<{ value: boolean }>;
+ openSettings(): Promise;
+}
+
+const NotificationPolicy = registerPlugin('NotificationPolicy', {
+ web: {
+ canBypassDnd: async () => ({ value: true }),
+ openSettings: async () => undefined,
+ },
+});
+
+export const canBypassDnd = async (): Promise => {
+ if (!isNativePlatform()) return true;
+ try {
+ const { value } = await NotificationPolicy.canBypassDnd();
+ return value;
+ } catch {
+ // Plugin not yet available (older APK installed before it shipped). Fail
+ // "allowed" so we don't nag — without the grant calls just don't ring through
+ // DND, which the user can resolve from Settings themselves.
+ return true;
+ }
+};
+
+export const openNotificationPolicySettings = async (): Promise => {
+ if (!isNativePlatform()) return;
+ await NotificationPolicy.openSettings();
+};