feat(calls): ring incoming calls through Do Not Disturb — bypass-DND notification channel gated on policy access, with one-time grant prompt

This commit is contained in:
heaven 2026-06-24 02:09:04 +03:00
parent 492e950245
commit 5d908bc04a
10 changed files with 287 additions and 9 deletions

View file

@ -166,6 +166,12 @@
requires the user-granted special app-op, surfaced to the user via
FullScreenIntentPlugin / FullScreenIntentPrompt. -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- Lets the incoming-call channel bypass Do Not Disturb / "Bedtime" so calls
ring through, like a real dialer (WhatsApp/Telegram). Declaring it makes the
app *eligible*; the user must still grant "Do Not Disturb access" once
(surfaced via NotificationPolicyPlugin / DndBypassPrompt). Without the grant,
setBypassDnd(true) on the channel is silently ignored. -->
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
<!-- IncomingCallActivity holds a short PARTIAL_WAKE_LOCK (bounded) so the CPU
stays awake while the full-screen ring screen is up. -->
<uses-permission android:name="android.permission.WAKE_LOCK" />

View file

@ -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

View file

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

View file

@ -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

View file

@ -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",

View file

@ -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": "Личные чаты",

View file

@ -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 (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleLater,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Dialog variant="Surface">
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">{t('Settings.dnd_prompt_title')}</Text>
</Box>
<IconButton size="300" onClick={handleLater} radii="300">
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text priority="400">{t('Settings.dnd_prompt_body')}</Text>
<Box direction="Row" gap="200" justifyContent="End">
<Button variant="Secondary" fill="Soft" onClick={handleLater}>
<Text size="B400">{t('Settings.fsi_prompt_later')}</Text>
</Button>
<Button variant="Primary" onClick={handleEnable}>
<Text size="B400">{t('Settings.fsi_prompt_open')}</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}

View file

@ -0,0 +1 @@
export * from './DndBypassPrompt';

View file

@ -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) {
<PushNotificationsFeature />
<PushPermissionPrompt />
<FullScreenIntentPrompt />
<DndBypassPrompt />
<AndroidBackButtonFeature />
<PendingDeclinesFlusherFeature />
{children}

View file

@ -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<void>;
}
const NotificationPolicy = registerPlugin<NotificationPolicyPlugin>('NotificationPolicy', {
web: {
canBypassDnd: async () => ({ value: true }),
openSettings: async () => undefined,
},
});
export const canBypassDnd = async (): Promise<boolean> => {
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<void> => {
if (!isNativePlatform()) return;
await NotificationPolicy.openSettings();
};