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 f0c0c45f..19dc9e40 100644 --- a/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java +++ b/android/app/src/main/java/chat/vojo/app/VojoFirebaseMessagingService.java @@ -29,13 +29,13 @@ import java.util.Map; * Message branch: builds a system notification when the activity is NOT in * the foreground — covering both "backgrounded" and "killed" cases. * - * 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 + * Call branch: when the app is backgrounded we show a CallStyle incoming-call + * notification with Answer/Decline actions + full-screen intent that wakes the + * device and launches MainActivity over the lockscreen. When the app is already + * foregrounded, JS owns the UX via the in-app incoming-call strip, so we must + * NOT also surface a system banner. 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 { @@ -72,7 +72,11 @@ public class VojoFirebaseMessagingService extends MessagingService { try { if (RTC_NOTIFICATION_TYPE.equals(data.get("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); return; } diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 51a5033d..7821671a 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -1,5 +1,5 @@ import React, { ReactNode, useCallback, useRef } from 'react'; -import { useAtomValue, useSetAtom } from 'jotai'; +import { useAtomValue, useSetAtom, useStore } from 'jotai'; import { CallEmbedContextProvider, CallEmbedRefContextProvider, @@ -15,14 +15,16 @@ import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; function CallUtils({ embed }: { embed: CallEmbed }) { const setCallEmbed = useSetAtom(callEmbedAtom); + const store = useStore(); useCallMemberSoundSync(embed); useCallThemeSync(embed); useCallHangupEvent( embed, useCallback(() => { + if (store.get(callEmbedAtom) !== embed) return; setCallEmbed(undefined); - }, [setCallEmbed]) + }, [store, embed, setCallEmbed]) ); return null; diff --git a/src/app/features/call-status/IncomingCallStrip.tsx b/src/app/features/call-status/IncomingCallStrip.tsx index f11c36b0..5428c85f 100644 --- a/src/app/features/call-status/IncomingCallStrip.tsx +++ b/src/app/features/call-status/IncomingCallStrip.tsx @@ -12,7 +12,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { UserAvatar } from '../../components/user-avatar'; import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; import { getCanonicalAliasOrRoomId, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; -import { useDmCallStart } from '../../hooks/useDmCallStart'; +import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall'; import { getDirectRoomPath } from '../../pages/pathUtils'; import { IncomingCall, incomingCallsAtom } from '../../state/incomingCalls'; import { getIncomingCallKey } from '../../utils/rtcNotification'; @@ -28,7 +28,7 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) { const mx = useMatrixClient(); const navigate = useNavigate(); const useAuthentication = useMediaAuthentication(); - const startDmCall = useDmCallStart(); + const switchOrStartDmCall = useSwitchOrStartDmCall(); const setIncoming = useSetAtom(incomingCallsAtom); const senderId = call.notifEvent.getSender(); @@ -45,9 +45,20 @@ export function IncomingCallStrip({ call, room }: IncomingCallStripProps) { const callKey = getIncomingCallKey(call.callId, call.roomId); const handleAnswer = () => { - setIncoming({ type: 'REMOVE', key: callKey }); - startDmCall(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 () => { diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index afc28629..f584f0ce 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -72,7 +72,7 @@ import { ContainerColor } from '../../styles/ContainerColor.css'; import { RoomSettingsPage } from '../../state/roomSettings'; import { useLivekitSupport } from '../../hooks/useLivekitSupport'; import { useCallMembers, useCallSession } from '../../hooks/useCall'; -import { useDmCallStart } from '../../hooks/useDmCallStart'; +import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall'; import { callEmbedAtom } from '../../state/callEmbed'; type RoomMenuProps = { @@ -263,7 +263,7 @@ const RoomMenu = forwardRef(({ room, requestClose function DmCallButton({ room }: { room: Room }) { const { t } = useTranslation(); const mx = useMatrixClient(); - const startDmCall = useDmCallStart(); + const switchOrStartDmCall = useSwitchOrStartDmCall(); const livekitSupported = useLivekitSupport(); const session = useCallSession(room); const members = useCallMembers(room, session); @@ -273,19 +273,20 @@ function DmCallButton({ room }: { room: Room }) { const inCallHere = currentEmbed?.roomId === room.roomId; if (inCallHere) return null; - const inCallElsewhere = !!currentEmbed && currentEmbed.roomId !== room.roomId; const ongoingByOthers = members.length > 0 && !members.some((m) => m.sender === myUserId); - const disabled = !livekitSupported || inCallElsewhere; + const disabled = !livekitSupported; let tooltipText: string; if (!livekitSupported) tooltipText = t('Call.unavailable'); - else if (inCallElsewhere) tooltipText = t('Call.busy_other_room'); else if (ongoingByOthers) tooltipText = t('Call.join'); else tooltipText = t('Call.start'); const handleClick = () => { 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 ( diff --git a/src/app/hooks/useCallerAutoHangup.ts b/src/app/hooks/useCallerAutoHangup.ts index 38697fce..0eef8dfc 100644 --- a/src/app/hooks/useCallerAutoHangup.ts +++ b/src/app/hooks/useCallerAutoHangup.ts @@ -10,9 +10,7 @@ // 3. Peer joins then leaves — memberships go empty → hangup after grace. // 4. Peer membership flaps on LiveKit reconnect — grace absorbs the blip. // -// KNOWN GAPS (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. +// KNOWN GAP (also in dm_calls_techdebt.md): // §5.18 Grace constants are empirically chosen, may need tuning with // real-world /sync + LiveKit reconnect metrics. // @@ -34,6 +32,7 @@ import { } from 'matrix-js-sdk'; import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession'; +import { IRTCNotificationContent } from 'matrix-js-sdk/lib/matrixrtc/types'; import { useMatrixClient } from './useMatrixClient'; import { callEmbedAtom } from '../state/callEmbed'; 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. 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(); + if (content.notification_type === 'ring') return ev; + } + } + + return undefined; +}; + export const useCallerAutoHangup = (): void => { const mx = useMatrixClient(); const callEmbed = useAtomValue(callEmbedAtom); @@ -56,7 +70,7 @@ export const useCallerAutoHangup = (): void => { useEffect(() => { if (!callEmbed) return undefined; - const roomId = callEmbed.roomId; + const { roomId } = callEmbed; if (!mDirect.has(roomId)) return undefined; const selfId = mx.getUserId(); @@ -71,6 +85,11 @@ export const useCallerAutoHangup = (): void => { let disposed = false; let peerSeen = session.memberships.some((m) => !isSelf(m)); let peerLeaveTimer: ReturnType | undefined; + let ownRingNotifEvent = findLatestOwnRingNotifEvent( + selfId, + callEmbed.room.getLiveTimeline()?.getEvents() ?? [] + ); + let pendingDeclineForNotifEventId: string | undefined; const performHangup = () => { 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(); + if (content.notification_type !== 'ring') return; + ownRingNotifEvent = ev; + if (pendingDeclineForNotifEventId === ev.getId()) { + performHangup(); + } + }; + const maybeHangupOnDecline = (ev: MatrixEvent): void => { // onDecrypted fires for every decrypted event client-wide; gate on room // first to avoid walking type/sender on unrelated rooms. if (ev.getRoomId() !== roomId) return; if (ev.getType() !== EventType.RTCDecline) 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(); }; @@ -123,20 +170,30 @@ export const useCallerAutoHangup = (): void => { // Encrypted RTCDecline lands as m.room.encrypted here; let the Decrypted // handler see it once cleartext is available. if (ev.isEncrypted()) { - mx.decryptEventIfNeeded(ev).catch(() => {}); + mx.decryptEventIfNeeded(ev).catch(() => undefined); return; } + maybeTrackOwnRing(ev); maybeHangupOnDecline(ev); }; const onDecrypted: MatrixEventHandlerMap[MatrixEventEvent.Decrypted] = (ev, err) => { if (err) return; + maybeTrackOwnRing(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); mx.on(RoomEvent.Timeline, onTimeline); mx.on(MatrixEventEvent.Decrypted, onDecrypted); + mx.on(RoomEvent.LocalEchoUpdated, onLocalEchoUpdated); return () => { disposed = true; @@ -145,6 +202,7 @@ export const useCallerAutoHangup = (): void => { session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships); mx.removeListener(RoomEvent.Timeline, onTimeline); mx.removeListener(MatrixEventEvent.Decrypted, onDecrypted); + mx.removeListener(RoomEvent.LocalEchoUpdated, onLocalEchoUpdated); }; }, [mx, callEmbed, mDirect, setCallEmbed]); }; diff --git a/src/app/hooks/useDismissNativeCallNotifications.ts b/src/app/hooks/useDismissNativeCallNotifications.ts index 4c514f50..a075e8ca 100644 --- a/src/app/hooks/useDismissNativeCallNotifications.ts +++ b/src/app/hooks/useDismissNativeCallNotifications.ts @@ -3,49 +3,7 @@ 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>(new Set()); - - useEffect(() => { - const nextRooms = new Set(); - 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]); -}; +const SUMMARY_NOTIFICATION_ID = -2147483648; // Reproduces java.lang.String#hashCode so JS-side ids match the tag ids the // Java service computed. Must stay in sync with CallCancelReceiver / the @@ -58,3 +16,52 @@ function javaStringHashCode(s: string): number { } 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>(new Set()); + + useEffect(() => { + const nextRooms = new Set(); + 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]); +}; diff --git a/src/app/hooks/useDmCallStart.ts b/src/app/hooks/useDmCallStart.ts index 5ad7acd6..6c1cce35 100644 --- a/src/app/hooks/useDmCallStart.ts +++ b/src/app/hooks/useDmCallStart.ts @@ -1,24 +1,16 @@ import { useCallback } from 'react'; -import { useAtomValue } from 'jotai'; -import { useMatrixClient } from './useMatrixClient'; -import { useCallStart } from './useCallEmbed'; -import { useCallPreferencesAtom } from '../state/hooks/callPreferences'; +import { useSwitchOrStartDmCall } from './useSwitchOrStartDmCall'; export const useDmCallStart = () => { - const mx = useMatrixClient(); - const startCall = useCallStart(true, true); - const callPref = useAtomValue(useCallPreferencesAtom()); + const switchOrStartDmCall = useSwitchOrStartDmCall(); return useCallback( (roomId: string) => { - const room = mx.getRoom(roomId); - if (!room) { + switchOrStartDmCall(roomId).catch((err: unknown) => { // eslint-disable-next-line no-console - console.warn('[dm-call] room not found', roomId); - return; - } - startCall(room, callPref); + console.warn('[dm-call] switch/start failed', err); + }); }, - [mx, startCall, callPref] + [switchOrStartDmCall] ); }; diff --git a/src/app/hooks/useIncomingRtcNotifications.ts b/src/app/hooks/useIncomingRtcNotifications.ts index 5c431a4c..f2a1e61a 100644 --- a/src/app/hooks/useIncomingRtcNotifications.ts +++ b/src/app/hooks/useIncomingRtcNotifications.ts @@ -90,10 +90,7 @@ const resolveCallId = async ( // Race: ring arrived before /sync delivered the membership state event. const session = mx.matrixRTC.getRoomSession(room); const waited = await new Promise((resolve) => { - const timeout = setTimeout(() => { - session.off(MatrixRTCSessionEvent.MembershipsChanged, handler); - resolve(undefined); - }, 5000); + let timeout: ReturnType; const handler = () => { const found = findCallIdSync(mx, room, sender, membershipEventId); if (found !== undefined) { @@ -102,6 +99,10 @@ const resolveCallId = async ( resolve(found); } }; + timeout = setTimeout(() => { + session.off(MatrixRTCSessionEvent.MembershipsChanged, handler); + resolve(undefined); + }, 5000); session.on(MatrixRTCSessionEvent.MembershipsChanged, handler); }); if (waited !== undefined) return waited; @@ -131,8 +132,6 @@ export const useIncomingRtcNotifications = (): void => { const mDirectRef = useRef(mDirect); mDirectRef.current = mDirect; - const inCallRef = useRef(callEmbed !== undefined); - inCallRef.current = callEmbed !== undefined; const registryRef = useRef>(new Map()); @@ -220,7 +219,6 @@ export const useIncomingRtcNotifications = (): void => { // Only DM ring — group call notifications use 'notification' type and are out of scope. if (content.notification_type !== 'ring') return; if (!mDirectRef.current.has(room.roomId)) return; - if (inCallRef.current) return; if (isRtcNotificationExpired(ev)) return; // 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 — // a join event from another device could have landed during the await. if (session.memberships.some((m) => m.sender === mx.getUserId())) return; - if (inCallRef.current) return; const key = getIncomingCallKey(callId, room.roomId); 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 // the Decrypted handler pick them up once the cleartext is available. if (ev.isEncrypted()) { - mx.decryptEventIfNeeded(ev).catch(() => {}); + mx.decryptEventIfNeeded(ev).catch(() => undefined); return; } processEvent(ev, room); diff --git a/src/app/hooks/usePendingCallActionConsumer.ts b/src/app/hooks/usePendingCallActionConsumer.ts index 937f6932..b8ec37cf 100644 --- a/src/app/hooks/usePendingCallActionConsumer.ts +++ b/src/app/hooks/usePendingCallActionConsumer.ts @@ -3,54 +3,66 @@ 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'; +import { useSwitchOrStartDmCall } from './useSwitchOrStartDmCall'; // 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. +// so useSwitchOrStartDmCall 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 switchOrStartDmCall = useSwitchOrStartDmCall(); 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 { - // 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: 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) => { + const { roomId, notifEventId } = pending; + setPending(undefined); + switchOrStartDmCall(roomId) + .then(() => { + if (notifEventId) { + setIncoming({ type: 'REMOVE_BY_NOTIF_ID', notifEventId }); + return; + } + setIncoming({ type: 'REMOVE_BY_ROOM', roomId }); + }) + .catch((err: unknown) => { // eslint-disable-next-line no-console - console.warn('[call] sendRtcDecline (from push action) failed', err); - minimize(); - } - ); + console.warn('[call] native answer switch/start failed', err); + }); + return; } + + const { roomId, notifEventId } = pending; 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]); }; diff --git a/src/app/hooks/usePushNotifications.ts b/src/app/hooks/usePushNotifications.ts index 9332a688..29cd55e0 100644 --- a/src/app/hooks/usePushNotifications.ts +++ b/src/app/hooks/usePushNotifications.ts @@ -341,14 +341,19 @@ export function usePushNotificationsLifecycle(): void { 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 // 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 }); + setPendingCallAction({ + kind: 'answer', + roomId: data.room_id, + notifEventId: data.notif_event_id, + }); return; } @@ -443,10 +448,12 @@ export function usePushNotificationsLifecycle(): void { // Display is handled entirely by the native VojoFirebaseMessagingService // (which fires for both backgrounded and killed process states via FCM's - // direct delivery). JS-side pushNotificationReceived would double-fire - // alongside native when the bridge is alive but backgrounded, so we don't - // subscribe to it here — foreground dedup stays the in-app responsibility - // (MessageNotifications cards), everything else goes through the native path. + // direct delivery). We intentionally do NOT subscribe to + // `pushNotificationReceived` here: doing so would create a second incoming + // call delivery path next to the native service / timeline listener pair. + // 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 // permission is still granted. The listener above picks up the token. diff --git a/src/app/hooks/useSwitchOrStartDmCall.ts b/src/app/hooks/useSwitchOrStartDmCall.ts new file mode 100644 index 00000000..3f51bc22 --- /dev/null +++ b/src/app/hooks/useSwitchOrStartDmCall.ts @@ -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) => { + const mx = useMatrixClient(); + const theme = useTheme(); + const store = useStore(); + const callEmbedRef = useCallEmbedRef(); + const callPref = useAtomValue(useCallPreferencesAtom()); + + const inFlightRef = useRef | undefined>(undefined); + + const waitLeave = useCallback( + (prev: CallEmbed): Promise => { + 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((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 => { + 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 => { + const enter = (): Promise => { + if (inFlightRef.current) { + return inFlightRef.current.then(enter, enter); + } + const task = doSwitchOrStart(roomId); + const tracked: Promise = task.finally(() => { + if (inFlightRef.current === tracked) inFlightRef.current = undefined; + }); + inFlightRef.current = tracked; + return tracked; + }; + return enter(); + }, + [doSwitchOrStart] + ); +}; diff --git a/src/app/pages/IncomingCallStripRenderer.tsx b/src/app/pages/IncomingCallStripRenderer.tsx index e8077901..852f88cb 100644 --- a/src/app/pages/IncomingCallStripRenderer.tsx +++ b/src/app/pages/IncomingCallStripRenderer.tsx @@ -3,11 +3,11 @@ // 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. +// IncomingCallStripRenderer is platform-agnostic: if JS knows about an incoming +// ring, we render the in-app strip. On Android the native FCM service decides +// independently whether to surface a system CallStyle notification; when the +// app is foregrounded it now suppresses that banner, so this renderer no longer +// needs to mirror native foreground policy in JS. // // 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 @@ -20,7 +20,6 @@ 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 @@ -32,12 +31,11 @@ export function IncomingCallStripRenderer() { const audioRef = useRef(null); const hasIncoming = incoming.size > 0; - const suppress = isNativePlatform(); useEffect(() => { const audio = audioRef.current; if (!audio) return; - if (hasIncoming && !suppress) { + if (hasIncoming) { audio.currentTime = 0; audio.play().catch(() => { // autoplay blocked — strip UI still visible @@ -46,9 +44,7 @@ export function IncomingCallStripRenderer() { audio.pause(); audio.currentTime = 0; } - }, [hasIncoming, suppress]); - - if (suppress) return null; + }, [hasIncoming]); const entries = Array.from(incoming.values()); diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index cdecce2c..362e6db3 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -29,6 +29,8 @@ import { CallControlState } from './CallControlState'; export class CallEmbed { private mx: MatrixClient; + private disposed = false; + public readonly call: ClientWidgetApi; public readonly iframe: HTMLIFrameElement; @@ -242,6 +244,9 @@ export class CallEmbed { * @param opts */ public dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.disposables.forEach((disposable) => { disposable(); }); diff --git a/src/app/state/pendingCallAction.ts b/src/app/state/pendingCallAction.ts index e8794111..ccc64571 100644 --- a/src/app/state/pendingCallAction.ts +++ b/src/app/state/pendingCallAction.ts @@ -3,9 +3,9 @@ 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. +// it and triggers switch-or-start / mx.sendRtcDecline. export type PendingCallAction = - | { kind: 'answer'; roomId: string } + | { kind: 'answer'; roomId: string; notifEventId?: string } | { kind: 'decline'; roomId: string; notifEventId: string }; export const pendingCallActionAtom = atom(undefined);