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:
parent
492e950245
commit
5d908bc04a
10 changed files with 287 additions and 9 deletions
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Личные чаты",
|
||||
|
|
|
|||
139
src/app/components/dnd-bypass-prompt/DndBypassPrompt.tsx
Normal file
139
src/app/components/dnd-bypass-prompt/DndBypassPrompt.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/app/components/dnd-bypass-prompt/index.ts
Normal file
1
src/app/components/dnd-bypass-prompt/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './DndBypassPrompt';
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
37
src/app/plugins/notificationPolicy.ts
Normal file
37
src/app/plugins/notificationPolicy.ts
Normal 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();
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue