dm calls mvp: phase 2.5.3: lockscreen CallStyle with FSI, Answer/Decline, ringtone channel, permission prompt

This commit is contained in:
heaven 2026-04-20 22:22:43 +03:00
parent aa958b6e76
commit 53a8bda18f
19 changed files with 841 additions and 30 deletions

View file

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

View file

@ -49,10 +49,24 @@
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<receiver
android:name=".CallCancelReceiver"
android:exported="false" />
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- DM voice calls: mic + audio routing. Capacitor auto-requests at getUserMedia time. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- Required to unblock NotificationCompat.CallStyle on API 31+: NMS's
checkDisqualifyingFeatures rejects CallStyle notifications without
FSI/FGS/UIJ, throwing IllegalArgumentException on its own handler
thread (silent to the app). Declaring the permission flips
FLAG_FSI_REQUESTED_BUT_DENIED so the gate passes, even though we
never call setFullScreenIntent(). See ADR 2.5-heads-up. -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
</manifest>

View file

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

View file

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

View file

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

View file

@ -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<String, String> 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<String, String> 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<String, String> 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;

View file

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

View file

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

View file

@ -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": "Отправлять уведомления на вашу почту.",

View file

@ -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 (
<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.fsi_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.fsi_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 './FullScreenIntentPrompt';

View file

@ -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<Set<string>>(new Set());
useEffect(() => {
const nextRooms = new Set<string>();
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;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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