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 ( + }> + + + +
+ + {t('Settings.dnd_prompt_title')} + + + + +
+ + {t('Settings.dnd_prompt_body')} + + + + + +
+
+
+
+ ); +} 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(); +};