Enforce DM call switching and foreground native call ownership

This commit is contained in:
v.lagerev 2026-04-22 21:27:54 +03:00
parent 5adbe294ef
commit fab533e762
14 changed files with 381 additions and 143 deletions

View file

@ -29,13 +29,13 @@ import java.util.Map;
* Message branch: builds a system notification when the activity is NOT in * Message branch: builds a system notification when the activity is NOT in
* the foreground covering both "backgrounded" and "killed" cases. * the foreground covering both "backgrounded" and "killed" cases.
* *
* Call branch: for `org.matrix.msc4075.rtc.notification` + `notification_type=ring` * Call branch: when the app is backgrounded we show a CallStyle incoming-call
* we always show a CallStyle incoming-call notification (independent of * notification with Answer/Decline actions + full-screen intent that wakes the
* foreground state) with Answer/Decline actions + full-screen intent that * device and launches MainActivity over the lockscreen. When the app is already
* wakes the device and launches MainActivity over the lockscreen the * foregrounded, JS owns the UX via the in-app incoming-call strip, so we must
* WhatsApp/Telegram incoming-call UX. The FSI is also what satisfies AOSP * NOT also surface a system banner. The FSI is also what satisfies AOSP
* NotificationManagerService.checkDisqualifyingFeatures on API 31+; without * NotificationManagerService.checkDisqualifyingFeatures on API 31+; without it
* it CallStyle throws IAE and the notification is silently dropped. See * CallStyle throws IAE and the notification is silently dropped. See
* docs/plans/dm_calls.md ADR 2.5-fsi. * docs/plans/dm_calls.md ADR 2.5-fsi.
*/ */
public class VojoFirebaseMessagingService extends MessagingService { public class VojoFirebaseMessagingService extends MessagingService {
@ -72,7 +72,11 @@ public class VojoFirebaseMessagingService extends MessagingService {
try { try {
if (RTC_NOTIFICATION_TYPE.equals(data.get("type")) if (RTC_NOTIFICATION_TYPE.equals(data.get("type"))
&& "ring".equals(data.get("content_notification_type"))) { && "ring".equals(data.get("content_notification_type"))) {
Log.d(TAG, "route: call-branch"); if (MainActivity.isInForeground) {
Log.d(TAG, "route: skip call notif (foreground, JS strip owns UX)");
return;
}
Log.d(TAG, "route: call-branch (background)");
showIncomingCallNotification(remoteMessage); showIncomingCallNotification(remoteMessage);
return; return;
} }

View file

@ -1,5 +1,5 @@
import React, { ReactNode, useCallback, useRef } from 'react'; import React, { ReactNode, useCallback, useRef } from 'react';
import { useAtomValue, useSetAtom } from 'jotai'; import { useAtomValue, useSetAtom, useStore } from 'jotai';
import { import {
CallEmbedContextProvider, CallEmbedContextProvider,
CallEmbedRefContextProvider, CallEmbedRefContextProvider,
@ -15,14 +15,16 @@ import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
function CallUtils({ embed }: { embed: CallEmbed }) { function CallUtils({ embed }: { embed: CallEmbed }) {
const setCallEmbed = useSetAtom(callEmbedAtom); const setCallEmbed = useSetAtom(callEmbedAtom);
const store = useStore();
useCallMemberSoundSync(embed); useCallMemberSoundSync(embed);
useCallThemeSync(embed); useCallThemeSync(embed);
useCallHangupEvent( useCallHangupEvent(
embed, embed,
useCallback(() => { useCallback(() => {
if (store.get(callEmbedAtom) !== embed) return;
setCallEmbed(undefined); setCallEmbed(undefined);
}, [setCallEmbed]) }, [store, embed, setCallEmbed])
); );
return null; return null;

View file

@ -12,7 +12,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { UserAvatar } from '../../components/user-avatar'; import { UserAvatar } from '../../components/user-avatar';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { getCanonicalAliasOrRoomId, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { getCanonicalAliasOrRoomId, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { useDmCallStart } from '../../hooks/useDmCallStart'; import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall';
import { getDirectRoomPath } from '../../pages/pathUtils'; import { getDirectRoomPath } from '../../pages/pathUtils';
import { IncomingCall, incomingCallsAtom } from '../../state/incomingCalls'; import { IncomingCall, incomingCallsAtom } from '../../state/incomingCalls';
import { getIncomingCallKey } from '../../utils/rtcNotification'; import { getIncomingCallKey } from '../../utils/rtcNotification';
@ -28,7 +28,7 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const navigate = useNavigate(); const navigate = useNavigate();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const startDmCall = useDmCallStart(); const switchOrStartDmCall = useSwitchOrStartDmCall();
const setIncoming = useSetAtom(incomingCallsAtom); const setIncoming = useSetAtom(incomingCallsAtom);
const senderId = call.notifEvent.getSender(); const senderId = call.notifEvent.getSender();
@ -45,9 +45,20 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) {
const callKey = getIncomingCallKey(call.callId, call.roomId); const callKey = getIncomingCallKey(call.callId, call.roomId);
const handleAnswer = () => { const handleAnswer = () => {
setIncoming({ type: 'REMOVE', key: callKey });
startDmCall(call.roomId);
navigate(getDirectRoomPath(getCanonicalAliasOrRoomId(mx, call.roomId))); navigate(getDirectRoomPath(getCanonicalAliasOrRoomId(mx, call.roomId)));
switchOrStartDmCall(call.roomId)
.then(() => {
const evId = call.notifEvent.getId();
if (evId) {
setIncoming({ type: 'REMOVE_BY_NOTIF_ID', notifEventId: evId });
return;
}
setIncoming({ type: 'REMOVE', key: callKey });
})
.catch((err: unknown) => {
// eslint-disable-next-line no-console
console.warn('[call] strip answer switch/start failed', err);
});
}; };
const handleDecline = async () => { const handleDecline = async () => {

View file

@ -72,7 +72,7 @@ import { ContainerColor } from '../../styles/ContainerColor.css';
import { RoomSettingsPage } from '../../state/roomSettings'; import { RoomSettingsPage } from '../../state/roomSettings';
import { useLivekitSupport } from '../../hooks/useLivekitSupport'; import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { useCallMembers, useCallSession } from '../../hooks/useCall'; import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { useDmCallStart } from '../../hooks/useDmCallStart'; import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall';
import { callEmbedAtom } from '../../state/callEmbed'; import { callEmbedAtom } from '../../state/callEmbed';
type RoomMenuProps = { type RoomMenuProps = {
@ -263,7 +263,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
function DmCallButton({ room }: { room: Room }) { function DmCallButton({ room }: { room: Room }) {
const { t } = useTranslation(); const { t } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
const startDmCall = useDmCallStart(); const switchOrStartDmCall = useSwitchOrStartDmCall();
const livekitSupported = useLivekitSupport(); const livekitSupported = useLivekitSupport();
const session = useCallSession(room); const session = useCallSession(room);
const members = useCallMembers(room, session); const members = useCallMembers(room, session);
@ -273,19 +273,20 @@ function DmCallButton({ room }: { room: Room }) {
const inCallHere = currentEmbed?.roomId === room.roomId; const inCallHere = currentEmbed?.roomId === room.roomId;
if (inCallHere) return null; if (inCallHere) return null;
const inCallElsewhere = !!currentEmbed && currentEmbed.roomId !== room.roomId;
const ongoingByOthers = members.length > 0 && !members.some((m) => m.sender === myUserId); const ongoingByOthers = members.length > 0 && !members.some((m) => m.sender === myUserId);
const disabled = !livekitSupported || inCallElsewhere; const disabled = !livekitSupported;
let tooltipText: string; let tooltipText: string;
if (!livekitSupported) tooltipText = t('Call.unavailable'); if (!livekitSupported) tooltipText = t('Call.unavailable');
else if (inCallElsewhere) tooltipText = t('Call.busy_other_room');
else if (ongoingByOthers) tooltipText = t('Call.join'); else if (ongoingByOthers) tooltipText = t('Call.join');
else tooltipText = t('Call.start'); else tooltipText = t('Call.start');
const handleClick = () => { const handleClick = () => {
if (disabled) return; if (disabled) return;
startDmCall(room.roomId); switchOrStartDmCall(room.roomId).catch((err: unknown) => {
// eslint-disable-next-line no-console
console.warn('[call] header switch/start failed', err);
});
}; };
return ( return (

View file

@ -10,9 +10,7 @@
// 3. Peer joins then leaves — memberships go empty → hangup after grace. // 3. Peer joins then leaves — memberships go empty → hangup after grace.
// 4. Peer membership flaps on LiveKit reconnect — grace absorbs the blip. // 4. Peer membership flaps on LiveKit reconnect — grace absorbs the blip.
// //
// KNOWN GAPS (also in dm_calls_techdebt.md): // KNOWN GAP (also in dm_calls_techdebt.md):
// §5.16 Decline is not tied to our specific ring event id. Any RTCDecline
// from peer in this room kills our call.
// §5.18 Grace constants are empirically chosen, may need tuning with // §5.18 Grace constants are empirically chosen, may need tuning with
// real-world /sync + LiveKit reconnect metrics. // real-world /sync + LiveKit reconnect metrics.
// //
@ -34,6 +32,7 @@ import {
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession'; import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
import { IRTCNotificationContent } from 'matrix-js-sdk/lib/matrixrtc/types';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { callEmbedAtom } from '../state/callEmbed'; import { callEmbedAtom } from '../state/callEmbed';
import { mDirectAtom } from '../state/mDirectList'; import { mDirectAtom } from '../state/mDirectList';
@ -48,6 +47,21 @@ const NO_ANSWER_GRACE_MS = 10_000;
// grace we'd kill a live call on every blip. // grace we'd kill a live call on every blip.
const PEER_LEAVE_GRACE_MS = 8_000; const PEER_LEAVE_GRACE_MS = 8_000;
const findLatestOwnRingNotifEvent = (
selfId: string,
events: MatrixEvent[]
): MatrixEvent | undefined => {
for (let i = events.length - 1; i >= 0; i -= 1) {
const ev = events[i];
if (ev.getSender() === selfId && ev.getType() === EventType.RTCNotification) {
const content = ev.getContent<IRTCNotificationContent>();
if (content.notification_type === 'ring') return ev;
}
}
return undefined;
};
export const useCallerAutoHangup = (): void => { export const useCallerAutoHangup = (): void => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const callEmbed = useAtomValue(callEmbedAtom); const callEmbed = useAtomValue(callEmbedAtom);
@ -56,7 +70,7 @@ export const useCallerAutoHangup = (): void => {
useEffect(() => { useEffect(() => {
if (!callEmbed) return undefined; if (!callEmbed) return undefined;
const roomId = callEmbed.roomId; const { roomId } = callEmbed;
if (!mDirect.has(roomId)) return undefined; if (!mDirect.has(roomId)) return undefined;
const selfId = mx.getUserId(); const selfId = mx.getUserId();
@ -71,6 +85,11 @@ export const useCallerAutoHangup = (): void => {
let disposed = false; let disposed = false;
let peerSeen = session.memberships.some((m) => !isSelf(m)); let peerSeen = session.memberships.some((m) => !isSelf(m));
let peerLeaveTimer: ReturnType<typeof setTimeout> | undefined; let peerLeaveTimer: ReturnType<typeof setTimeout> | undefined;
let ownRingNotifEvent = findLatestOwnRingNotifEvent(
selfId,
callEmbed.room.getLiveTimeline()?.getEvents() ?? []
);
let pendingDeclineForNotifEventId: string | undefined;
const performHangup = () => { const performHangup = () => {
if (disposed) return; if (disposed) return;
@ -108,12 +127,40 @@ export const useCallerAutoHangup = (): void => {
} }
}; };
const maybeTrackOwnRing = (ev: MatrixEvent): void => {
if (ev.getRoomId() !== roomId) return;
if (ev.getType() !== EventType.RTCNotification) return;
if (ev.getSender() !== selfId) return;
const content = ev.getContent<IRTCNotificationContent>();
if (content.notification_type !== 'ring') return;
ownRingNotifEvent = ev;
if (pendingDeclineForNotifEventId === ev.getId()) {
performHangup();
}
};
const maybeHangupOnDecline = (ev: MatrixEvent): void => { const maybeHangupOnDecline = (ev: MatrixEvent): void => {
// onDecrypted fires for every decrypted event client-wide; gate on room // onDecrypted fires for every decrypted event client-wide; gate on room
// first to avoid walking type/sender on unrelated rooms. // first to avoid walking type/sender on unrelated rooms.
if (ev.getRoomId() !== roomId) return; if (ev.getRoomId() !== roomId) return;
if (ev.getType() !== EventType.RTCDecline) return; if (ev.getType() !== EventType.RTCDecline) return;
if (ev.getSender() === selfId) return; if (ev.getSender() === selfId) return;
const declinedNotifEventId = ev.getRelation()?.event_id;
if (!declinedNotifEventId) return;
// Same-room retries can leave multiple historical ring events behind.
// We only honor a decline that targets the ring this embed most recently
// sent; older declines must not tear down the new attempt.
if (!ownRingNotifEvent) {
pendingDeclineForNotifEventId = declinedNotifEventId;
return;
}
if (declinedNotifEventId !== ownRingNotifEvent.getId()) {
if (ownRingNotifEvent.getAssociatedStatus() !== null) {
pendingDeclineForNotifEventId = declinedNotifEventId;
}
return;
}
performHangup(); performHangup();
}; };
@ -123,20 +170,30 @@ export const useCallerAutoHangup = (): void => {
// Encrypted RTCDecline lands as m.room.encrypted here; let the Decrypted // Encrypted RTCDecline lands as m.room.encrypted here; let the Decrypted
// handler see it once cleartext is available. // handler see it once cleartext is available.
if (ev.isEncrypted()) { if (ev.isEncrypted()) {
mx.decryptEventIfNeeded(ev).catch(() => {}); mx.decryptEventIfNeeded(ev).catch(() => undefined);
return; return;
} }
maybeTrackOwnRing(ev);
maybeHangupOnDecline(ev); maybeHangupOnDecline(ev);
}; };
const onDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = (ev, err) => { const onDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = (ev, err) => {
if (err) return; if (err) return;
maybeTrackOwnRing(ev);
maybeHangupOnDecline(ev); maybeHangupOnDecline(ev);
}; };
const onLocalEchoUpdated: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (ev, room) => {
if (!room || room.roomId !== roomId) return;
if (ev !== ownRingNotifEvent) return;
if (pendingDeclineForNotifEventId !== ev.getId()) return;
performHangup();
};
session.on(MatrixRTCSessionEvent.MembershipsChanged, onMemberships); session.on(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
mx.on(RoomEvent.Timeline, onTimeline); mx.on(RoomEvent.Timeline, onTimeline);
mx.on(MatrixEventEvent.Decrypted, onDecrypted); mx.on(MatrixEventEvent.Decrypted, onDecrypted);
mx.on(RoomEvent.LocalEchoUpdated, onLocalEchoUpdated);
return () => { return () => {
disposed = true; disposed = true;
@ -145,6 +202,7 @@ export const useCallerAutoHangup = (): void => {
session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships); session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
mx.removeListener(RoomEvent.Timeline, onTimeline); mx.removeListener(RoomEvent.Timeline, onTimeline);
mx.removeListener(MatrixEventEvent.Decrypted, onDecrypted); mx.removeListener(MatrixEventEvent.Decrypted, onDecrypted);
mx.removeListener(RoomEvent.LocalEchoUpdated, onLocalEchoUpdated);
}; };
}, [mx, callEmbed, mDirect, setCallEmbed]); }, [mx, callEmbed, mDirect, setCallEmbed]);
}; };

View file

@ -3,49 +3,7 @@ import { useAtomValue } from 'jotai';
import { incomingCallsAtom } from '../state/incomingCalls'; import { incomingCallsAtom } from '../state/incomingCalls';
import { isNativePlatform } from '../utils/capacitor'; import { isNativePlatform } from '../utils/capacitor';
// When the in-app incomingCallsAtom drops an entry (user accepted, declined, const SUMMARY_NOTIFICATION_ID = -2147483648;
// 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 // Reproduces java.lang.String#hashCode so JS-side ids match the tag ids the
// Java service computed. Must stay in sync with CallCancelReceiver / the // Java service computed. Must stay in sync with CallCancelReceiver / the
@ -58,3 +16,52 @@ function javaStringHashCode(s: string): number {
} }
return h; return h;
} }
// Keep native CallStyle in sync with the JS-owned incoming-calls atom. When JS
// either loses a ring (accepted, declined, other device joined, lifetime
// expired) OR newly takes ownership of one after the app returns foreground,
// clear the system notification for that room. The AlarmManager fallback in the
// Java service still handles killed-process dismiss on lifetime expiry.
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 changed: string[] = [];
prevRoomsRef.current.forEach((roomId) => {
if (!nextRooms.has(roomId)) changed.push(roomId);
});
nextRooms.forEach((roomId) => {
if (!prevRoomsRef.current.has(roomId)) changed.push(roomId);
});
prevRoomsRef.current = nextRooms;
if (changed.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 = changed.map((roomId) => {
const tag = `call_${roomId}`;
let id = javaStringHashCode(tag);
if (id === SUMMARY_NOTIFICATION_ID) id += 1;
return { id, 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]);
};

View file

@ -1,24 +1,16 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useAtomValue } from 'jotai'; import { useSwitchOrStartDmCall } from './useSwitchOrStartDmCall';
import { useMatrixClient } from './useMatrixClient';
import { useCallStart } from './useCallEmbed';
import { useCallPreferencesAtom } from '../state/hooks/callPreferences';
export const useDmCallStart = () => { export const useDmCallStart = () => {
const mx = useMatrixClient(); const switchOrStartDmCall = useSwitchOrStartDmCall();
const startCall = useCallStart(true, true);
const callPref = useAtomValue(useCallPreferencesAtom());
return useCallback( return useCallback(
(roomId: string) => { (roomId: string) => {
const room = mx.getRoom(roomId); switchOrStartDmCall(roomId).catch((err: unknown) => {
if (!room) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn('[dm-call] room not found', roomId); console.warn('[dm-call] switch/start failed', err);
return; });
}
startCall(room, callPref);
}, },
[mx, startCall, callPref] [switchOrStartDmCall]
); );
}; };

View file

@ -90,10 +90,7 @@ const resolveCallId = async (
// Race: ring arrived before /sync delivered the membership state event. // Race: ring arrived before /sync delivered the membership state event.
const session = mx.matrixRTC.getRoomSession(room); const session = mx.matrixRTC.getRoomSession(room);
const waited = await new Promise<string | undefined>((resolve) => { const waited = await new Promise<string | undefined>((resolve) => {
const timeout = setTimeout(() => { let timeout: ReturnType<typeof setTimeout>;
session.off(MatrixRTCSessionEvent.MembershipsChanged, handler);
resolve(undefined);
}, 5000);
const handler = () => { const handler = () => {
const found = findCallIdSync(mx, room, sender, membershipEventId); const found = findCallIdSync(mx, room, sender, membershipEventId);
if (found !== undefined) { if (found !== undefined) {
@ -102,6 +99,10 @@ const resolveCallId = async (
resolve(found); resolve(found);
} }
}; };
timeout = setTimeout(() => {
session.off(MatrixRTCSessionEvent.MembershipsChanged, handler);
resolve(undefined);
}, 5000);
session.on(MatrixRTCSessionEvent.MembershipsChanged, handler); session.on(MatrixRTCSessionEvent.MembershipsChanged, handler);
}); });
if (waited !== undefined) return waited; if (waited !== undefined) return waited;
@ -131,8 +132,6 @@ export const useIncomingRtcNotifications = (): void => {
const mDirectRef = useRef(mDirect); const mDirectRef = useRef(mDirect);
mDirectRef.current = mDirect; mDirectRef.current = mDirect;
const inCallRef = useRef(callEmbed !== undefined);
inCallRef.current = callEmbed !== undefined;
const registryRef = useRef<Map<string, RegistryEntry>>(new Map()); const registryRef = useRef<Map<string, RegistryEntry>>(new Map());
@ -220,7 +219,6 @@ export const useIncomingRtcNotifications = (): void => {
// Only DM ring — group call notifications use 'notification' type and are out of scope. // Only DM ring — group call notifications use 'notification' type and are out of scope.
if (content.notification_type !== 'ring') return; if (content.notification_type !== 'ring') return;
if (!mDirectRef.current.has(room.roomId)) return; if (!mDirectRef.current.has(room.roomId)) return;
if (inCallRef.current) return;
if (isRtcNotificationExpired(ev)) return; if (isRtcNotificationExpired(ev)) return;
// Already participating in the room session → suppress duplicate toast. // Already participating in the room session → suppress duplicate toast.
@ -241,7 +239,6 @@ export const useIncomingRtcNotifications = (): void => {
// Re-check membership after the (possibly networked) callId resolve — // Re-check membership after the (possibly networked) callId resolve —
// a join event from another device could have landed during the await. // a join event from another device could have landed during the await.
if (session.memberships.some((m) => m.sender === mx.getUserId())) return; if (session.memberships.some((m) => m.sender === mx.getUserId())) return;
if (inCallRef.current) return;
const key = getIncomingCallKey(callId, room.roomId); const key = getIncomingCallKey(callId, room.roomId);
if (registry.has(key)) return; if (registry.has(key)) return;
@ -274,7 +271,7 @@ export const useIncomingRtcNotifications = (): void => {
// Encrypted events land here as m.room.encrypted; kick decryption and let // Encrypted events land here as m.room.encrypted; kick decryption and let
// the Decrypted handler pick them up once the cleartext is available. // the Decrypted handler pick them up once the cleartext is available.
if (ev.isEncrypted()) { if (ev.isEncrypted()) {
mx.decryptEventIfNeeded(ev).catch(() => {}); mx.decryptEventIfNeeded(ev).catch(() => undefined);
return; return;
} }
processEvent(ev, room); processEvent(ev, room);

View file

@ -3,54 +3,66 @@ import { useAtomValue, useSetAtom } from 'jotai';
import { App } from '@capacitor/app'; import { App } from '@capacitor/app';
import { pendingCallActionAtom } from '../state/pendingCallAction'; import { pendingCallActionAtom } from '../state/pendingCallAction';
import { incomingCallsAtom } from '../state/incomingCalls'; import { incomingCallsAtom } from '../state/incomingCalls';
import { useDmCallStart } from './useDmCallStart';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { isNativePlatform } from '../utils/capacitor'; import { isNativePlatform } from '../utils/capacitor';
import { useSwitchOrStartDmCall } from './useSwitchOrStartDmCall';
// Consumes pending call actions emitted by the native Android push-action // Consumes pending call actions emitted by the native Android push-action
// listener (see usePushNotifications.ts). Must be mounted inside CallEmbedProvider // listener (see usePushNotifications.ts). Must be mounted inside CallEmbedProvider
// so useDmCallStart can reach the embed atom. // so useSwitchOrStartDmCall can reach the embed atom.
export const usePendingCallActionConsumer = (): void => { export const usePendingCallActionConsumer = (): void => {
const pending = useAtomValue(pendingCallActionAtom); const pending = useAtomValue(pendingCallActionAtom);
const setPending = useSetAtom(pendingCallActionAtom); const setPending = useSetAtom(pendingCallActionAtom);
const setIncoming = useSetAtom(incomingCallsAtom); const setIncoming = useSetAtom(incomingCallsAtom);
const startDmCall = useDmCallStart(); const switchOrStartDmCall = useSwitchOrStartDmCall();
const mx = useMatrixClient(); const mx = useMatrixClient();
useEffect(() => { useEffect(() => {
if (!pending) return; if (!pending) return;
if (pending.kind === 'answer') { if (pending.kind === 'answer') {
// In-app strip (if any) is stale after accept — drop it so the overlay const { roomId, notifEventId } = pending;
// can own the UX without a parallel decline button lingering. setPending(undefined);
setIncoming({ type: 'REMOVE_BY_ROOM', roomId: pending.roomId }); switchOrStartDmCall(roomId)
startDmCall(pending.roomId); .then(() => {
} else { if (notifEventId) {
// Unreachable in practice after techdebt §5.35 landed: every Decline setIncoming({ type: 'REMOVE_BY_NOTIF_ID', notifEventId });
// button press now fires CallDeclineReceiver via PendingIntent.getBroadcast, return;
// so MainActivity never boots and `pushNotificationActionPerformed` never }
// fires with call_action='decline'. Nothing else queues a decline onto setIncoming({ type: 'REMOVE_BY_ROOM', roomId });
// pendingCallActionAtom. Kept as a safety-net in case a future JS-path })
// (in-app banner decline, retry flow, etc.) starts emitting here. .catch((err: unknown) => {
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.
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 // eslint-disable-next-line no-console
console.warn('[call] sendRtcDecline (from push action) failed', err); console.warn('[call] native answer switch/start failed', err);
minimize(); });
} return;
);
} }
const { roomId, notifEventId } = pending;
setPending(undefined); setPending(undefined);
}, [pending, setPending, setIncoming, startDmCall, mx]); // Unreachable in practice after techdebt §5.35 landed: every Decline
// button press now fires CallDeclineReceiver via PendingIntent.getBroadcast,
// so MainActivity never boots and `pushNotificationActionPerformed` never
// fires with call_action='decline'. Nothing else queues a decline onto
// pendingCallActionAtom. Kept as a safety-net in case a future JS-path
// (in-app banner decline, retry flow, etc.) starts emitting here.
setIncoming({ type: 'REMOVE_BY_NOTIF_ID', 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.
const minimize = () => {
if (!isNativePlatform()) return;
App.minimizeApp().catch(() => {
/* minimize not supported / already in background */
});
};
mx.sendRtcDecline(roomId, notifEventId).then(
() => minimize(),
(err: unknown) => {
// eslint-disable-next-line no-console
console.warn('[call] sendRtcDecline (from push action) failed', err);
minimize();
}
);
}, [pending, setPending, setIncoming, switchOrStartDmCall, mx]);
}; };

View file

@ -341,14 +341,19 @@ export function usePushNotificationsLifecycle(): void {
notif_event_id?: string; notif_event_id?: string;
}; };
// Native CallStyle Answer → open the room and queue an auto-join via // Native CallStyle Answer → open the room and queue a JS-side
// switch/start via
// pendingCallActionAtom. The consumer hook picks it up once the // pendingCallActionAtom. The consumer hook picks it up once the
// CallEmbedProvider tree is mounted. DM rooms live in the Direct tab // CallEmbedProvider tree is mounted. DM rooms live in the Direct tab
// route; `getHomeRoomPath` resolves to a Home-tab placeholder for IDs // route; `getHomeRoomPath` resolves to a Home-tab placeholder for IDs
// it doesn't have in its left-rail, hence the DM path here. // it doesn't have in its left-rail, hence the DM path here.
if (data.call_action === 'answer' && data.room_id) { if (data.call_action === 'answer' && data.room_id) {
navigate(getDirectRoomPath(data.room_id)); navigate(getDirectRoomPath(data.room_id));
setPendingCallAction({ kind: 'answer', roomId: data.room_id }); setPendingCallAction({
kind: 'answer',
roomId: data.room_id,
notifEventId: data.notif_event_id,
});
return; return;
} }
@ -443,10 +448,12 @@ export function usePushNotificationsLifecycle(): void {
// Display is handled entirely by the native VojoFirebaseMessagingService // Display is handled entirely by the native VojoFirebaseMessagingService
// (which fires for both backgrounded and killed process states via FCM's // (which fires for both backgrounded and killed process states via FCM's
// direct delivery). JS-side pushNotificationReceived would double-fire // direct delivery). We intentionally do NOT subscribe to
// alongside native when the bridge is alive but backgrounded, so we don't // `pushNotificationReceived` here: doing so would create a second incoming
// subscribe to it here — foreground dedup stays the in-app responsibility // call delivery path next to the native service / timeline listener pair.
// (MessageNotifications cards), everything else goes through the native path. // Foreground incoming-call UX is owned by the in-app strip via
// useIncomingRtcNotifications; background/killed UX is owned by native
// CallStyle. Keep this invariant unless the whole dedup model changes.
// Kick off token delivery on startup if push was previously enabled and // Kick off token delivery on startup if push was previously enabled and
// permission is still granted. The listener above picks up the token. // permission is still granted. The listener above picks up the token.

View file

@ -0,0 +1,146 @@
// DM call entry point that unifies start, join and switch flows.
//
// Contract (dm_calls_techdebt.md §5.36):
// - no prev embed → start a new DM call
// - prev.roomId === arg → no-op (healthy same-room click)
// - prev.roomId !== arg → switch: hangup prev, wait for clean leave,
// dispose prev, then start new
//
// Switch barrier waits for one of two success signals:
// 1. MembershipsChanged removing our (user, device) from prev room session
// — server-authoritative, this is what the peer actually observes
// 2. action:im.vector.hangup from the widget — widget-side confirmation,
// including pre-join / not-yet-member states where there is nothing to
// observe via MembershipsChanged
// 3. 3s timeout — fail-closed: abort switch, do not start the new call
//
// Listeners are armed BEFORE hangup() to avoid missing a fast action ack.
//
// The function is serialized via inFlightRef: concurrent double-taps await the
// same promise and then re-enter. After re-entry the same-room branch usually
// makes the second tap a noop, matching user intent.
import { useCallback, useRef } from 'react';
import { useAtomValue, useStore } from 'jotai';
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
import { useMatrixClient } from './useMatrixClient';
import { useTheme } from './useTheme';
import { createCallEmbed, useCallEmbedRef } from './useCallEmbed';
import { useCallPreferencesAtom } from '../state/hooks/callPreferences';
import { callEmbedAtom } from '../state/callEmbed';
import { CallEmbed } from '../plugins/call';
const SWITCH_LEAVE_TIMEOUT_MS = 3000;
const createSwitchTimeoutError = (roomId: string): Error =>
new Error(`[dm-call] switch timed out waiting for clean leave in ${roomId}`);
export const useSwitchOrStartDmCall = (): ((roomId: string) => Promise<void>) => {
const mx = useMatrixClient();
const theme = useTheme();
const store = useStore();
const callEmbedRef = useCallEmbedRef();
const callPref = useAtomValue(useCallPreferencesAtom());
const inFlightRef = useRef<Promise<void> | undefined>(undefined);
const waitLeave = useCallback(
(prev: CallEmbed): Promise<void> => {
const session = mx.matrixRTC.getRoomSession(prev.room);
const selfUserId = mx.getSafeUserId();
const selfDeviceId = mx.getDeviceId();
const selfPresent = (memberships: CallMembership[]): boolean =>
memberships.some(
(m) => m.sender === selfUserId && (!selfDeviceId || m.deviceId === selfDeviceId)
);
return new Promise<void>((resolve, reject) => {
let done = false;
// Assigned once listeners are wired — set to a real cleanup below
// so the pre-wire window is never reachable (finish can only fire
// after the listeners/timer exist).
let cleanup: () => void = () => undefined;
const finish = () => {
if (done) return;
done = true;
cleanup();
resolve();
};
const fail = () => {
if (done) return;
done = true;
cleanup();
reject(createSwitchTimeoutError(prev.roomId));
};
const onMemberships = (_old: CallMembership[], next: CallMembership[]): void => {
if (!selfPresent(next)) finish();
};
const onHangupAction = (): void => finish();
session.on(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
prev.call.on('action:im.vector.hangup', onHangupAction);
const timer = setTimeout(fail, SWITCH_LEAVE_TIMEOUT_MS);
cleanup = () => {
session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
prev.call.off('action:im.vector.hangup', onHangupAction);
clearTimeout(timer);
};
prev.hangup().catch((err: unknown) => {
// eslint-disable-next-line no-console
console.warn('[dm-call] hangup transport fail (barrier still armed)', err);
});
});
},
[mx]
);
const doSwitchOrStart = useCallback(
async (roomId: string): Promise<void> => {
const room = mx.getRoom(roomId);
if (!room) {
// eslint-disable-next-line no-console
console.warn('[dm-call] room not found', roomId);
return;
}
const prev = store.get(callEmbedAtom);
if (prev?.roomId === roomId) return;
const container = callEmbedRef.current;
if (!container) {
throw new Error('Failed to start call, No embed container element found!');
}
if (prev) {
await waitLeave(prev);
prev.dispose();
}
const embed = createCallEmbed(mx, room, true, theme.kind, container, callPref, true);
store.set(callEmbedAtom, embed);
},
[mx, store, callEmbedRef, callPref, theme, waitLeave]
);
return useCallback(
(roomId: string): Promise<void> => {
const enter = (): Promise<void> => {
if (inFlightRef.current) {
return inFlightRef.current.then(enter, enter);
}
const task = doSwitchOrStart(roomId);
const tracked: Promise<void> = task.finally(() => {
if (inFlightRef.current === tracked) inFlightRef.current = undefined;
});
inFlightRef.current = tracked;
return tracked;
};
return enter();
},
[doSwitchOrStart]
);
};

View file

@ -3,11 +3,11 @@
// Mounted in Router.tsx inside `CallEmbedProvider`, rendered right before // Mounted in Router.tsx inside `CallEmbedProvider`, rendered right before
// `CallStatusRenderer` so the strip stacks above the in-call pill. // `CallStatusRenderer` so the strip stacks above the in-call pill.
// //
// On native Android the OS CallStyle notification (see // IncomingCallStripRenderer is platform-agnostic: if JS knows about an incoming
// VojoFirebaseMessagingService) owns the incoming-call UX end-to-end: heads-up, // ring, we render the in-app strip. On Android the native FCM service decides
// ringtone, Answer/Decline buttons, full-screen wakeup. Rendering the in-app // independently whether to surface a system CallStyle notification; when the
// strip in parallel just duplicates UI and plays a second ringtone on top of // app is foregrounded it now suppresses that banner, so this renderer no longer
// the system one — so the renderer is a no-op on native. // needs to mirror native foreground policy in JS.
// //
// KNOWN GAP §5.17: if the browser blocks `audio.play()` (cold page load, no // 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 // user gesture yet), the ring is silent — strip is still visible but user may
@ -20,7 +20,6 @@ import { Box } from 'folds';
import { incomingCallsAtom } from '../state/incomingCalls'; import { incomingCallsAtom } from '../state/incomingCalls';
import { useMatrixClient } from '../hooks/useMatrixClient'; import { useMatrixClient } from '../hooks/useMatrixClient';
import { IncomingCallStrip } from '../features/call-status'; import { IncomingCallStrip } from '../features/call-status';
import { isNativePlatform } from '../utils/capacitor';
// eslint-disable-next-line import/no-relative-packages // eslint-disable-next-line import/no-relative-packages
import RingSoundOgg from '../../../public/sound/ring.ogg'; import RingSoundOgg from '../../../public/sound/ring.ogg';
// eslint-disable-next-line import/no-relative-packages // eslint-disable-next-line import/no-relative-packages
@ -32,12 +31,11 @@ export function IncomingCallStripRenderer() {
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const hasIncoming = incoming.size > 0; const hasIncoming = incoming.size > 0;
const suppress = isNativePlatform();
useEffect(() => { useEffect(() => {
const audio = audioRef.current; const audio = audioRef.current;
if (!audio) return; if (!audio) return;
if (hasIncoming && !suppress) { if (hasIncoming) {
audio.currentTime = 0; audio.currentTime = 0;
audio.play().catch(() => { audio.play().catch(() => {
// autoplay blocked — strip UI still visible // autoplay blocked — strip UI still visible
@ -46,9 +44,7 @@ export function IncomingCallStripRenderer() {
audio.pause(); audio.pause();
audio.currentTime = 0; audio.currentTime = 0;
} }
}, [hasIncoming, suppress]); }, [hasIncoming]);
if (suppress) return null;
const entries = Array.from(incoming.values()); const entries = Array.from(incoming.values());

View file

@ -29,6 +29,8 @@ import { CallControlState } from './CallControlState';
export class CallEmbed { export class CallEmbed {
private mx: MatrixClient; private mx: MatrixClient;
private disposed = false;
public readonly call: ClientWidgetApi; public readonly call: ClientWidgetApi;
public readonly iframe: HTMLIFrameElement; public readonly iframe: HTMLIFrameElement;
@ -242,6 +244,9 @@ export class CallEmbed {
* @param opts * @param opts
*/ */
public dispose(): void { public dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.disposables.forEach((disposable) => { this.disposables.forEach((disposable) => {
disposable(); disposable();
}); });

View file

@ -3,9 +3,9 @@ import { atom } from 'jotai';
// Bridge between the native Capacitor push-action listener (which fires outside // 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 // any React render context) and the in-app call flow. The listener sets the
// atom; usePendingCallActionConsumer — mounted inside CallEmbedProvider — reads // atom; usePendingCallActionConsumer — mounted inside CallEmbedProvider — reads
// it and triggers useDmCallStart / mx.sendRtcDecline. // it and triggers switch-or-start / mx.sendRtcDecline.
export type PendingCallAction = export type PendingCallAction =
| { kind: 'answer'; roomId: string } | { kind: 'answer'; roomId: string; notifEventId?: string }
| { kind: 'decline'; roomId: string; notifEventId: string }; | { kind: 'decline'; roomId: string; notifEventId: string };
export const pendingCallActionAtom = atom<PendingCallAction | undefined>(undefined); export const pendingCallActionAtom = atom<PendingCallAction | undefined>(undefined);