Enforce DM call switching and foreground native call ownership
This commit is contained in:
parent
5adbe294ef
commit
fab533e762
14 changed files with 381 additions and 143 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement, RoomMenuProps>(({ 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 (
|
||||
|
|
|
|||
|
|
@ -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<IRTCNotificationContent>();
|
||||
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<typeof setTimeout> | 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<IRTCNotificationContent>();
|
||||
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]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<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]);
|
||||
};
|
||||
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<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]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<string | undefined>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
session.off(MatrixRTCSessionEvent.MembershipsChanged, handler);
|
||||
resolve(undefined);
|
||||
}, 5000);
|
||||
let timeout: ReturnType<typeof setTimeout>;
|
||||
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<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.
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -3,35 +3,49 @@ 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 {
|
||||
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] native answer switch/start failed', err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { roomId, notifEventId } = pending;
|
||||
setPending(undefined);
|
||||
// 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 });
|
||||
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;
|
||||
|
|
@ -42,7 +56,7 @@ export const usePendingCallActionConsumer = (): void => {
|
|||
/* minimize not supported / already in background */
|
||||
});
|
||||
};
|
||||
mx.sendRtcDecline(pending.roomId, pending.notifEventId).then(
|
||||
mx.sendRtcDecline(roomId, notifEventId).then(
|
||||
() => minimize(),
|
||||
(err: unknown) => {
|
||||
// eslint-disable-next-line no-console
|
||||
|
|
@ -50,7 +64,5 @@ export const usePendingCallActionConsumer = (): void => {
|
|||
minimize();
|
||||
}
|
||||
);
|
||||
}
|
||||
setPending(undefined);
|
||||
}, [pending, setPending, setIncoming, startDmCall, mx]);
|
||||
}, [pending, setPending, setIncoming, switchOrStartDmCall, mx]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
146
src/app/hooks/useSwitchOrStartDmCall.ts
Normal file
146
src/app/hooks/useSwitchOrStartDmCall.ts
Normal 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]
|
||||
);
|
||||
};
|
||||
|
|
@ -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<HTMLAudioElement>(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());
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<PendingCallAction | undefined>(undefined);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue