fix(calls): close glare cross-call races and lifecycle gaps

Re-dial after a crossed/cancelled DM call now rings again: DM 'ongoing' is peer-scoped so a stale own membership no longer forces a join_existing intent (which drops the ring), and an outgoing start waits for our own membership to leave; zombie same-room dispose first gives the widget a bounded chance to send its leave. Plus: incoming-ring suppress ignores stale own-device membership, useCallJoined resets per embed, no-answer timer re-anchors to the ring actually sent, mute mirror tracks last-sent state, SW self-dismiss checks eventId, Telecom disconnect clears a dead-widget atom, and a same-room join marks the native session answered. From the internal multi-agent review.
This commit is contained in:
heaven 2026-07-04 02:06:31 +03:00
parent b88e056dcd
commit 64517a73f7
11 changed files with 329 additions and 101 deletions

View file

@ -13,7 +13,6 @@ import androidx.core.telecom.CallsManager
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
@ -178,6 +177,13 @@ class VojoCallsManager private constructor(context: Context) {
if (fb != null && fb !== js) action(fb) if (fb != null && fb !== js) action(fb)
} }
// Debug-only verbose log. Call metadata (roomId, gen, cause codes) must not
// reach prod logcat, mirroring the dlog idiom of the sibling FCM code (F33).
// Errors/warnings stay on Log.e/Log.w with their Throwables.
private fun dlog(msg: String) {
if (BuildConfig.DEBUG) Log.d(TAG, msg)
}
/** True while any Telecom session (ringing or active) is live. */ /** True while any Telecom session (ringing or active) is live. */
fun hasSession(): Boolean = controlScope != null fun hasSession(): Boolean = controlScope != null
@ -222,10 +228,16 @@ class VojoCallsManager private constructor(context: Context) {
// audio is implied. Streaming (Wear/Auto media) is not declared. // audio is implied. Streaming (Wear/Auto media) is not declared.
callsManager.registerAppWithTelecom(CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING) callsManager.registerAppWithTelecom(CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING)
registered = true registered = true
Log.d(TAG, "registerAppWithTelecom ok") dlog("registerAppWithTelecom ok")
} catch (t: Throwable) { } catch (t: Throwable) {
Log.e(TAG, "registerAppWithTelecom failed", t) Log.e(TAG, "registerAppWithTelecom failed", t)
activeListener()?.onError("register_failed: " + (t.message ?: t.javaClass.simpleName)) // Deliver to BOTH listeners (H1): during an unanswered ring the JS
// plugin listener is installed but not subscribed to telecom events, so
// routing only to activeListener() would drop the failure and the
// native ring cleanup/boot would never run.
notifyLifecycle {
it.onError("register_failed: " + (t.message ?: t.javaClass.simpleName))
}
} }
} }
@ -247,11 +259,20 @@ class VojoCallsManager private constructor(context: Context) {
// still match here and NOT teardown + re-add, which would drop the // still match here and NOT teardown + re-add, which would drop the
// just-answered ring. So we no longer gate on controlScope != null. // just-answered ring. So we no longer gate on controlScope != null.
if (currentRoomId == roomId) { if (currentRoomId == roomId) {
Log.d(TAG, "startCall: session already live/forming for room=$roomId") dlog("startCall: session already live/forming for room=$roomId")
// Ensure an outgoing/join-time call goes active once the scope // A JS outgoing/join for the SAME room as a ring-created incoming
// exists; if it isn't published yet the addCall block (outgoing) // session (answered=false) means the ring has been consumed by the
// or pendingAnswer (incoming) will activate it. // user joining — mark it answered so a later removeIncomingRing for
if (!incoming && controlScope != null) setCallActive() // the now-stale ring event (ceiling / suppress / decline) tears down
// only the ring bookkeeping in maybeTeardownTelecomRing, NOT this
// live, connected call + its FGS (F3). answered is sticky for the
// rest of the session.
if (!incoming) {
answered = true
// Ensure the call goes active once the scope exists; if it
// isn't published yet the addCall block will activate it.
if (controlScope != null) setCallActive()
}
return return
} }
// Single active call invariant: supersede any prior (different-room) // Single active call invariant: supersede any prior (different-room)
@ -286,7 +307,7 @@ class VojoCallsManager private constructor(context: Context) {
// fallback which boots the app for a BT/Auto answer). Ignored // fallback which boots the app for a BT/Auto answer). Ignored
// if this session was superseded (stale generation). // if this session was superseded (stale generation).
{ callType -> { callType ->
Log.d(TAG, "onAnswer callType=$callType") dlog("onAnswer callType=$callType")
if (myGen == sessionGen.get()) { if (myGen == sessionGen.get()) {
answered = true answered = true
val isVideo = callType == CallAttributesCompat.CALL_TYPE_VIDEO_CALL val isVideo = callType == CallAttributesCompat.CALL_TYPE_VIDEO_CALL
@ -299,7 +320,7 @@ class VojoCallsManager private constructor(context: Context) {
// Stale (superseded) disconnects are dropped so they can't // Stale (superseded) disconnects are dropped so they can't
// tear down the NEW call. // tear down the NEW call.
{ cause -> { cause ->
Log.d(TAG, "onDisconnect cause=${cause.code}") dlog("onDisconnect cause=${cause.code}")
if (myGen == sessionGen.get()) { if (myGen == sessionGen.get()) {
clearSessionState(myGen) clearSessionState(myGen)
notifyLifecycle { it.onDisconnect(cause.code) } notifyLifecycle { it.onDisconnect(cause.code) }
@ -307,7 +328,7 @@ class VojoCallsManager private constructor(context: Context) {
}, },
// onSetActive (resume from hold). // onSetActive (resume from hold).
{ {
Log.d(TAG, "onSetActive") dlog("onSetActive")
if (myGen == sessionGen.get()) { if (myGen == sessionGen.get()) {
answered = true answered = true
activeListener()?.onSetActive() activeListener()?.onSetActive()
@ -315,7 +336,7 @@ class VojoCallsManager private constructor(context: Context) {
}, },
// onSetInactive (hold, e.g. interrupting cellular call). // onSetInactive (hold, e.g. interrupting cellular call).
{ {
Log.d(TAG, "onSetInactive") dlog("onSetInactive")
if (myGen == sessionGen.get()) activeListener()?.onSetInactive() if (myGen == sessionGen.get()) activeListener()?.onSetInactive()
}, },
) { ) {
@ -331,7 +352,7 @@ class VojoCallsManager private constructor(context: Context) {
if (!superseded) controlScope = this if (!superseded) controlScope = this
} }
if (superseded) { if (superseded) {
Log.d(TAG, "addCall accepted but superseded — self-disconnecting gen=$myGen") dlog("addCall accepted but superseded — self-disconnecting gen=$myGen")
launch { launch {
try { try {
disconnect(DisconnectCause(DisconnectCause.LOCAL)) disconnect(DisconnectCause(DisconnectCause.LOCAL))
@ -340,9 +361,9 @@ class VojoCallsManager private constructor(context: Context) {
} }
} }
} else { } else {
Log.d(TAG, "addCall accepted: session live (incoming=$incoming) gen=$myGen") dlog("addCall accepted: session live (incoming=$incoming) gen=$myGen")
if (!incoming) { if (!incoming) {
launch { Log.d(TAG, "setActive -> ${setActive()}") } launch { dlog("setActive -> ${setActive()}") }
} else { } else {
// A UI answer that landed before the session was live — // A UI answer that landed before the session was live —
// honor it now (read under the lock to pair with answerCall). // honor it now (read under the lock to pair with answerCall).
@ -372,7 +393,7 @@ class VojoCallsManager private constructor(context: Context) {
// of this scope and cancel automatically when the call ends. // of this scope and cancel automatically when the call ends.
launch { launch {
currentCallEndpoint.collect { ep -> currentCallEndpoint.collect { ep ->
Log.d(TAG, "endpoint -> ${typeToRoute(ep.type)}") dlog("endpoint -> ${typeToRoute(ep.type)}")
if (myGen == sessionGen.get()) { if (myGen == sessionGen.get()) {
activeListener()?.onEndpointChanged(typeToRoute(ep.type)) activeListener()?.onEndpointChanged(typeToRoute(ep.type))
} }
@ -390,7 +411,7 @@ class VojoCallsManager private constructor(context: Context) {
} }
launch { launch {
isMuted.collect { muted -> isMuted.collect { muted ->
Log.d(TAG, "mute -> $muted") dlog("mute -> $muted")
if (myGen == sessionGen.get()) { if (myGen == sessionGen.get()) {
activeListener()?.onMuteStateChanged(muted) activeListener()?.onMuteStateChanged(muted)
} }
@ -423,12 +444,24 @@ class VojoCallsManager private constructor(context: Context) {
fun answerCall(roomId: String, video: Boolean) { fun answerCall(roomId: String, video: Boolean) {
val cs: CallControlScope? val cs: CallControlScope?
synchronized(lock) { synchronized(lock) {
// Room-aware: only the ring that actually owns the session can be // Require a live/forming session for THIS room. currentRoomId is set
// answered. currentRoomId is set synchronously by startCall even // synchronously by startCall even before the session goes live, so this
// before the session goes live, so this is reliable during the // stays reliable during the answer-from-killed window. The null case
// answer-from-killed window. // matters too: after a Telecom register/addCall failure the ring degrades
if (currentRoomId != null && currentRoomId != roomId) { // to notification-only (F11) and currentRoomId is cleared — an answer for
Log.w(TAG, "answerCall: room mismatch (live=$currentRoomId), ignoring") // it must NOT strand answered/pendingAnswer=true on a session-less manager
// (self-review); the JS join proceeds independently.
if (currentRoomId != roomId) {
Log.w(TAG, "answerCall: no matching session (live=$currentRoomId), ignoring")
return
}
// Already answered AND active — a duplicate answerCall (the native
// IncomingCallActivity answer plus the MainActivity shade-answer bridge
// both fire it) must not launch a second Telecom answer() transaction on
// an already-non-RINGING call (self-review). The pendingAnswer path
// (controlScope not yet published) still needs to run, so gate on scope.
if (answered && controlScope != null) {
dlog("answerCall: already answered + active, ignoring duplicate")
return return
} }
// Flip out of RINGING synchronously and unconditionally so a // Flip out of RINGING synchronously and unconditionally so a
@ -499,7 +532,7 @@ class VojoCallsManager private constructor(context: Context) {
cs.launch { cs.launch {
try { try {
val result = cs.requestEndpointChange(target) val result = cs.requestEndpointChange(target)
Log.d(TAG, "requestEndpoint $route -> $result") dlog("requestEndpoint $route -> $result")
} catch (t: Throwable) { } catch (t: Throwable) {
Log.w(TAG, "requestEndpoint failed", t) Log.w(TAG, "requestEndpoint failed", t)
} }
@ -524,7 +557,7 @@ class VojoCallsManager private constructor(context: Context) {
pendingAnswerVideo = false pendingAnswerVideo = false
availableEndpointList = emptyList() availableEndpointList = emptyList()
if (cs == null) return if (cs == null) return
Log.d(TAG, "teardown: disconnect cause=$causeCode") dlog("teardown: disconnect cause=$causeCode")
cs.launch { cs.launch {
try { try {
cs.disconnect(DisconnectCause(causeCode)) cs.disconnect(DisconnectCause(causeCode))

View file

@ -3,6 +3,7 @@ import { MatrixClient, Room } from 'matrix-js-sdk';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { import {
CallEmbed, CallEmbed,
ElementCallIntent,
ElementCallThemeKind, ElementCallThemeKind,
ElementWidgetActions, ElementWidgetActions,
useClientWidgetApiEvent, useClientWidgetApiEvent,
@ -45,9 +46,34 @@ export const createCallEmbed = (
incoming = false incoming = false
): CallEmbed => { ): CallEmbed => {
const rtcSession = mx.matrixRTC.getRoomSession(room); const rtcSession = mx.matrixRTC.getRoomSession(room);
const ongoing = rtcSession.memberships.length > 0; // "ongoing" decides the start-vs-join intent. For a DM it must mean "someone
// OTHER than this device is already in the call" (a peer, or our own second
// device) — NOT merely "any membership exists". Our own just-disposed
// membership (from a call whose leave the session hasn't observed yet) would
// otherwise force a join_existing intent, and the EC widget emits the ring
// ONLY for a start_call_dm intent when it joins as the first member — so an
// outgoing ring on a glare re-dial would be silently dropped (F1). Group calls
// keep the plain any-member semantics.
const selfUserId = mx.getUserId();
const selfDeviceId = mx.getDeviceId();
const ongoing = dm
? rtcSession.memberships.some(
(m) => m.userId !== selfUserId || (!!selfDeviceId && m.deviceId !== selfDeviceId)
)
: rtcSession.memberships.length > 0;
const intent = CallEmbed.getIntent(dm, ongoing, voiceOnly); // Answering an incoming ring must NEVER start a fresh ring: force a
// join_existing intent regardless of membership. If the caller cancelled in
// the same instant their membership can already read as gone → ongoing=false →
// a start intent → the answerer's widget joins as first member and rings the
// caller back with a phantom "incoming" call (F6). Only DM carries the ring;
// the incoming flag is DM-only, so this is scoped there.
let intent: ElementCallIntent;
if (dm && incoming) {
intent = voiceOnly ? ElementCallIntent.JoinExistingDMVoice : ElementCallIntent.JoinExistingDM;
} else {
intent = CallEmbed.getIntent(dm, ongoing, voiceOnly);
}
const widget = CallEmbed.getWidget(mx, room, intent, themeKind); const widget = CallEmbed.getWidget(mx, room, intent, themeKind);
const controlState = const controlState =
pref && new CallControlState(pref.microphone, voiceOnly ? false : pref.video); pref && new CallControlState(pref.microphone, voiceOnly ? false : pref.video);
@ -81,6 +107,19 @@ export const useCallStart = (dm = false, voiceOnly = false) => {
export const useCallJoined = (embed?: CallEmbed): boolean => { export const useCallJoined = (embed?: CallEmbed): boolean => {
const [joined, setJoined] = useState(embed?.joined ?? false); const [joined, setJoined] = useState(embed?.joined ?? false);
// Reset joined to THIS embed's own state on every embed instance change,
// including a direct A→B swap that never passes through `undefined` (same-room
// re-dial via the zombie branch, cross-room switch). Done during render (the
// React "adjust state on prop change" idiom) rather than in an effect so B
// never inherits A's joined=true for even one commit — otherwise every
// joined-keyed consumer (Telecom session, FGS, over-lock) would fire a
// premature startCall→endCall thrash for B before its own JoinCall, violating
// the keyed-on-JoinCall contract (F5).
const [trackedEmbed, setTrackedEmbed] = useState(embed);
if (embed !== trackedEmbed) {
setTrackedEmbed(embed);
setJoined(embed?.joined ?? false);
}
useClientWidgetApiEvent( useClientWidgetApiEvent(
embed?.call, embed?.call,
@ -90,12 +129,6 @@ export const useCallJoined = (embed?: CallEmbed): boolean => {
}, []) }, [])
); );
useEffect(() => {
if (!embed) {
setJoined(false);
}
}, [embed]);
return joined; return joined;
}; };

View file

@ -40,17 +40,13 @@ import {
getNotificationEventSendTs, getNotificationEventSendTs,
getRtcNotificationLifetime, getRtcNotificationLifetime,
RTC_NOTIFICATION_DEFAULT_LIFETIME, RTC_NOTIFICATION_DEFAULT_LIFETIME,
RTC_PEER_LEAVE_GRACE_MS,
} from '../utils/rtcNotification'; } from '../utils/rtcNotification';
// Grace beyond the ring lifetime before giving up on no-answer. Covers /sync // Grace beyond the ring lifetime before giving up on no-answer. Covers /sync
// latency between B joining and A seeing the membership state event. // latency between B joining and A seeing the membership state event.
const NO_ANSWER_GRACE_MS = 10_000; const NO_ANSWER_GRACE_MS = 10_000;
// Wait after the peer's last membership drops before tearing down. Matrix RTC
// memberships can flap on LiveKit reconnect / short network hiccups; without a
// grace we'd kill a live call on every blip.
const PEER_LEAVE_GRACE_MS = 8_000;
const findLatestOwnRingNotifEvent = ( const findLatestOwnRingNotifEvent = (
selfId: string, selfId: string,
events: MatrixEvent[] events: MatrixEvent[]
@ -140,12 +136,21 @@ export const useCallerAutoHangup = (): void => {
// instant — bail in a few seconds instead of sitting in a dead call for ~40s // instant — bail in a few seconds instead of sitting in a dead call for ~40s
// with a live mic (RED-7). A late /sync membership still cancels this timer // with a live mic (RED-7). A late /sync membership still cancels this timer
// via onMemberships before it fires. // via onMemberships before it fires.
const noAnswerTimer: ReturnType<typeof setTimeout> | undefined = peerSeen let noAnswerTimer: ReturnType<typeof setTimeout> | undefined;
? undefined const clearNoAnswerTimer = () => {
: setTimeout( if (noAnswerTimer) {
performHangup, clearTimeout(noAnswerTimer);
callEmbed.incoming ? PEER_LEAVE_GRACE_MS : computeNoAnswerDelay() noAnswerTimer = undefined;
); }
};
const armNoAnswerTimer = () => {
clearNoAnswerTimer();
noAnswerTimer = setTimeout(
performHangup,
callEmbed.incoming ? RTC_PEER_LEAVE_GRACE_MS : computeNoAnswerDelay()
);
};
if (!peerSeen) armNoAnswerTimer();
const onMemberships = (_prev: CallMembership[], next: CallMembership[]) => { const onMemberships = (_prev: CallMembership[], next: CallMembership[]) => {
const peerPresent = next.some(isPeer); const peerPresent = next.some(isPeer);
@ -153,12 +158,12 @@ export const useCallerAutoHangup = (): void => {
clearPeerLeaveTimer(); clearPeerLeaveTimer();
if (!peerSeen) { if (!peerSeen) {
peerSeen = true; peerSeen = true;
if (noAnswerTimer) clearTimeout(noAnswerTimer); clearNoAnswerTimer();
} }
return; return;
} }
if (peerSeen && !peerLeaveTimer) { if (peerSeen && !peerLeaveTimer) {
peerLeaveTimer = setTimeout(performHangup, PEER_LEAVE_GRACE_MS); peerLeaveTimer = setTimeout(performHangup, RTC_PEER_LEAVE_GRACE_MS);
} }
}; };
@ -169,6 +174,16 @@ export const useCallerAutoHangup = (): void => {
const content = ev.getContent<IRTCNotificationContent>(); const content = ev.getContent<IRTCNotificationContent>();
if (content.notification_type !== 'ring') return; if (content.notification_type !== 'ring') return;
ownRingNotifEvent = ev; ownRingNotifEvent = ev;
// Re-anchor the caller's no-answer deadline to the ring THIS embed actually
// sent. On a fast re-dial the initial ownRingNotifEvent found in the
// timeline was the PREVIOUS attempt's ring (our fresh ring is sent by the
// widget seconds after JoinCall and only appears via /sync); a timer armed
// off that stale, still-in-future deadline fires early and hangs up before
// the callee's ring expires (F19). Only while the timer is still pending
// (peer hasn't answered) and only for the caller (answerers use the fixed
// grace, unaffected by our own ring's send-ts). Not after teardown — a late
// ring event must not re-arm a spurious timer on an already-hung-up call.
if (!disposed && !callEmbed.incoming && noAnswerTimer) armNoAnswerTimer();
if (pendingDeclineForNotifEventId === ev.getId()) { if (pendingDeclineForNotifEventId === ev.getId()) {
performHangup(); performHangup();
} }
@ -237,7 +252,7 @@ export const useCallerAutoHangup = (): void => {
return () => { return () => {
disposed = true; disposed = true;
if (noAnswerTimer) clearTimeout(noAnswerTimer); clearNoAnswerTimer();
clearPeerLeaveTimer(); clearPeerLeaveTimer();
session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships); session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
mx.removeListener(RoomEvent.Timeline, onTimeline); mx.removeListener(RoomEvent.Timeline, onTimeline);

View file

@ -40,15 +40,11 @@ import {
getNotificationEventSendTs, getNotificationEventSendTs,
getRtcNotificationLifetime, getRtcNotificationLifetime,
isRtcNotificationExpired, isRtcNotificationExpired,
RTC_NOTIFICATION_DEFAULT_LIFETIME, RTC_NOTIFICATION_MAX_LIFETIME,
RTC_PEER_LEAVE_GRACE_MS,
} from '../utils/rtcNotification'; } from '../utils/rtcNotification';
import { callForegroundService } from '../plugins/call/callForegroundService'; import { callForegroundService } from '../plugins/call/callForegroundService';
// Grace before withdrawing a ring when the calling peer's RTC membership goes
// empty (the online "caller cancelled" path). Absorbs brief LiveKit-reconnect
// membership flaps so a hiccup doesn't drop a still-live ring.
const PEER_LEAVE_GRACE_MS = 8_000;
// Web only: ask the service worker to close the OS-level ring banner for a room. // Web only: ask the service worker to close the OS-level ring banner for a room.
// On Android the native registry owns the CallStyle (callForegroundService.*); // On Android the native registry owns the CallStyle (callForegroundService.*);
// on web the SW is the only context that can close a notification it posted, so // on web the SW is the only context that can close a notification it posted, so
@ -181,6 +177,12 @@ export const useIncomingRtcNotifications = (): void => {
const mDirectRef = useRef(mDirect); const mDirectRef = useRef(mDirect);
mDirectRef.current = mDirect; mDirectRef.current = mDirect;
// Live-embed snapshot for the suppress check below. The processEvent closure
// lives in an effect that does NOT depend on callEmbed, so read it through a
// ref to see the current value without re-subscribing every call.
const callEmbedRef = useRef(callEmbed);
callEmbedRef.current = callEmbed;
const registryRef = useRef<Map<string, RegistryEntry>>(new Map()); const registryRef = useRef<Map<string, RegistryEntry>>(new Map());
// Notification IDs whose RTCDecline already arrived — stops a late-decrypted // Notification IDs whose RTCDecline already arrived — stops a late-decrypted
@ -300,7 +302,7 @@ export const useIncomingRtcNotifications = (): void => {
setTimeout(() => { setTimeout(() => {
peerLeaveTimers.delete(room.roomId); peerLeaveTimers.delete(room.roomId);
removeByRoom(room.roomId); removeByRoom(room.roomId);
}, PEER_LEAVE_GRACE_MS) }, RTC_PEER_LEAVE_GRACE_MS)
); );
} }
}; };
@ -318,9 +320,13 @@ export const useIncomingRtcNotifications = (): void => {
const rememberDeclined = (notifEventId: string) => { const rememberDeclined = (notifEventId: string) => {
const existing = declinedTimers.get(notifEventId); const existing = declinedTimers.get(notifEventId);
if (existing) clearTimeout(existing); if (existing) clearTimeout(existing);
// Hold the decline tombstone for the MAX ring lifetime, not the default
// 30s: a ring may legitimately carry a longer lifetime, and a shorter
// tombstone let a late-delivered (>30s) already-declined ring resurrect
// (F31).
const timer = setTimeout(() => { const timer = setTimeout(() => {
declinedTimers.delete(notifEventId); declinedTimers.delete(notifEventId);
}, RTC_NOTIFICATION_DEFAULT_LIFETIME); }, RTC_NOTIFICATION_MAX_LIFETIME);
declinedTimers.set(notifEventId, timer); declinedTimers.set(notifEventId, timer);
}; };
@ -367,6 +373,10 @@ export const useIncomingRtcNotifications = (): void => {
/* best-effort */ /* best-effort */
}); });
} }
// Web: a suppressed ring is never ADDed to the atom, so the [incoming]
// sync-effect can't close its SW banner. Withdraw it here or it lingers
// until self-dismiss (~30-95s). No-op on native (F12).
closeWebRing(room.roomId);
}; };
const content = ev.getContent<IRTCNotificationContent>(); const content = ev.getContent<IRTCNotificationContent>();
@ -385,9 +395,24 @@ export const useIncomingRtcNotifications = (): void => {
return; return;
} }
// Already participating in the room session → suppress duplicate toast. // Suppress the toast only when we are GENUINELY in this call — NOT when a
// stale own-device membership from a just-ended call still lingers in the
// async-recomputed session cache. Genuine = a live callEmbed for this room
// (in the call on THIS device) OR our membership on ANOTHER device
// (answered elsewhere / multi-device). Suppressing on a stale same-device
// membership dropped the peer's fresh ring — and tombstoned it — during
// the glare-cancel window (F4). The re-check after the resolveCallId await
// uses the same predicate.
const session = mx.matrixRTC.getRoomSession(room); const session = mx.matrixRTC.getRoomSession(room);
if (session.memberships.some((m) => m.userId === mx.getUserId())) { const genuinelyParticipating = (): boolean => {
if (callEmbedRef.current?.roomId === room.roomId) return true;
const selfId = mx.getUserId();
const selfDeviceId = mx.getDeviceId();
return session.memberships.some(
(m) => m.userId === selfId && !!selfDeviceId && m.deviceId !== selfDeviceId
);
};
if (genuinelyParticipating()) {
removeFromRegistry(); removeFromRegistry();
return; return;
} }
@ -412,9 +437,9 @@ export const useIncomingRtcNotifications = (): void => {
// Re-check anything that can change during the await. resolveCallId can // Re-check anything that can change during the await. resolveCallId can
// yield for seconds (5s MembershipsChanged wait, then fetchRoomEvent) — // yield for seconds (5s MembershipsChanged wait, then fetchRoomEvent) —
// a membership join or a matching decline can land meanwhile and must be // a genuine join or a matching decline can land meanwhile and must be
// observed before we commit the ADD. // observed before we commit the ADD (same predicate as the pre-await check).
if (session.memberships.some((m) => m.userId === mx.getUserId())) { if (genuinelyParticipating()) {
removeFromRegistry(); removeFromRegistry();
return; return;
} }

View file

@ -24,6 +24,7 @@
// makes the second tap a noop, matching user intent. // makes the second tap a noop, matching user intent.
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { Room } from 'matrix-js-sdk';
import { useAtomValue, useStore } from 'jotai'; import { useAtomValue, useStore } from 'jotai';
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';
@ -35,6 +36,16 @@ import { callEmbedAtom } from '../state/callEmbed';
import { CallEmbed } from '../plugins/call'; import { CallEmbed } from '../plugins/call';
const SWITCH_LEAVE_TIMEOUT_MS = 3000; const SWITCH_LEAVE_TIMEOUT_MS = 3000;
// A joined zombie widget (peer gone / stuck) is given a SHORTER window to send
// its clean leave before we dispose it — it may be unresponsive, so we don't
// burn the full switch budget. If it does respond, our membership clears and the
// re-dial's ring survives the SDK first-member gate (F2).
const ZOMBIE_LEAVE_TIMEOUT_MS = 1500;
// Before starting an OUTGOING DM call, wait (bounded) for our own membership from
// a prior call in this room to actually leave the session, so the EC widget joins
// with oldMemberships empty and its ring notification fires (F1). Resolves
// instantly when we hold no membership (the common fresh-call case).
const OWN_LEAVE_TIMEOUT_MS = 2500;
const createSwitchTimeoutError = (roomId: string): Error => const createSwitchTimeoutError = (roomId: string): Error =>
new Error(`[dm-call] switch timed out waiting for clean leave in ${roomId}`); new Error(`[dm-call] switch timed out waiting for clean leave in ${roomId}`);
@ -52,7 +63,7 @@ export const useSwitchOrStartDmCall = (): ((
const inFlightRef = useRef<Promise<void> | undefined>(undefined); const inFlightRef = useRef<Promise<void> | undefined>(undefined);
const waitLeave = useCallback( const waitLeave = useCallback(
(prev: CallEmbed): Promise<void> => { (prev: CallEmbed, timeoutMs: number = SWITCH_LEAVE_TIMEOUT_MS): Promise<void> => {
const session = mx.matrixRTC.getRoomSession(prev.room); const session = mx.matrixRTC.getRoomSession(prev.room);
const selfUserId = mx.getSafeUserId(); const selfUserId = mx.getSafeUserId();
const selfDeviceId = mx.getDeviceId(); const selfDeviceId = mx.getDeviceId();
@ -87,7 +98,7 @@ export const useSwitchOrStartDmCall = (): ((
session.on(MatrixRTCSessionEvent.MembershipsChanged, onMemberships); session.on(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
prev.call.on('action:im.vector.hangup', onHangupAction); prev.call.on('action:im.vector.hangup', onHangupAction);
const timer = setTimeout(fail, SWITCH_LEAVE_TIMEOUT_MS); const timer = setTimeout(fail, timeoutMs);
cleanup = () => { cleanup = () => {
session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships); session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
@ -104,6 +115,47 @@ export const useSwitchOrStartDmCall = (): ((
[mx] [mx]
); );
// Resolve once our own (user, device) membership is gone from the room's RTC
// session, or after `timeoutMs` (fail-open). Used before (re)starting an
// outgoing DM call so the EC widget joins as the first member — matrix-js-sdk
// sends the ring notification ONLY when oldMemberships.length === 0, so a
// lingering own membership from a just-ended call silently drops the ring
// (F1). Instant when we already hold no membership.
const waitOwnMembershipGone = useCallback(
(room: Room, timeoutMs: number): Promise<void> => {
const session = mx.matrixRTC.getRoomSession(room);
const selfUserId = mx.getSafeUserId();
const selfDeviceId = mx.getDeviceId();
const ownGone = (memberships: CallMembership[]): boolean =>
!memberships.some(
(m) => m.userId === selfUserId && (!selfDeviceId || m.deviceId === selfDeviceId)
);
if (ownGone(session.memberships)) return Promise.resolve();
return new Promise<void>((resolve) => {
let done = false;
// Assigned once the listener/timer exist (mirrors waitLeave) so `finish`
// never references them before they are defined.
let cleanup: () => void = () => undefined;
const finish = () => {
if (done) return;
done = true;
cleanup();
resolve();
};
const onMemberships = (_old: CallMembership[], next: CallMembership[]): void => {
if (ownGone(next)) finish();
};
session.on(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
const timer = setTimeout(finish, timeoutMs);
cleanup = () => {
session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
clearTimeout(timer);
};
});
},
[mx]
);
const doSwitchOrStart = useCallback( const doSwitchOrStart = useCallback(
async (roomId: string, incoming: boolean): Promise<void> => { async (roomId: string, incoming: boolean): Promise<void> => {
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
@ -115,14 +167,16 @@ export const useSwitchOrStartDmCall = (): ((
const prev = store.get(callEmbedAtom); const prev = store.get(callEmbedAtom);
if (prev?.roomId === roomId) { if (prev?.roomId === roomId) {
// Healthy same-room call → no-op. "Peer present" is USER-scoped: only the
// OTHER user answering makes the call healthy. A device-scoped check
// counted our own second device as a peer, so a lingering own membership
// from another device (non-MSC4140 HS) wedged every re-dial tap into a
// silent no-op for the membership's whole life (F8). Mirrors isPeer in
// useCallerAutoHangup.
const selfUserId = mx.getSafeUserId(); const selfUserId = mx.getSafeUserId();
const selfDeviceId = mx.getDeviceId();
const peerPresent = mx.matrixRTC const peerPresent = mx.matrixRTC
.getRoomSession(prev.room) .getRoomSession(prev.room)
.memberships.some( .memberships.some((m: CallMembership) => m.userId !== selfUserId);
(m: CallMembership) =>
m.userId !== selfUserId || (!!selfDeviceId && m.deviceId !== selfDeviceId)
);
if (prev.joined && peerPresent) return; if (prev.joined && peerPresent) return;
} }
@ -131,16 +185,31 @@ export const useSwitchOrStartDmCall = (): ((
throw new Error('Failed to start call, No embed container element found!'); throw new Error('Failed to start call, No embed container element found!');
} }
// Tracks a same-room zombie whose waitLeave TIMED OUT (unresponsive widget).
// In that case the widget can never send its leave, so the own-membership
// wait below would just burn its full fail-open budget for nothing — a 2nd
// black-screen delay stacked on the zombie timeout (self-review regression).
// We skip it there; a widget that DID respond (or a fresh start / switch)
// still runs the wait, which is instant once our membership is gone.
let zombieLeaveTimedOut = false;
if (prev) { if (prev) {
if (prev.roomId === roomId) { if (prev.roomId === roomId) {
// Zombie same-room embed (widget never joined, peer gone, or stuck // Zombie same-room embed (widget never joined, peer gone, or stuck
// after LiveKit reconnect fail). waitLeave would hang on the // after LiveKit reconnect fail). Give a JOINED widget a bounded chance
// unresponsive widget. Best-effort widget hangup before direct // to send its server-side leave before disposing: waitLeave arms the
// dispose so the widget's MembershipManager gets a chance to send // membership/hangup barrier, then hangs up. If the widget responds our
// the server-side leave event; if it doesn't, the SDK's delayed // membership clears (so the re-dial rings — F1/F2); if it's
// leave fires a few seconds after dispose. // unresponsive the short timeout fails open and we dispose anyway. A
// never-joined zombie has no membership to leave, so skip straight to
// dispose.
if (prev.joined) { if (prev.joined) {
prev.hangup().catch(() => undefined); try {
await waitLeave(prev, ZOMBIE_LEAVE_TIMEOUT_MS);
} catch {
// fail-open: unresponsive widget — dispose + start regardless, and
// don't wait again below (its membership won't clear either way).
zombieLeaveTimedOut = true;
}
} }
prev.dispose(); prev.dispose();
} else { } else {
@ -162,6 +231,16 @@ export const useSwitchOrStartDmCall = (): ((
} }
} }
// Outgoing (not answering): make sure our own membership from a prior call
// in this room has actually left before creating the widget, or the SDK's
// first-member ring gate drops the outgoing ring (F1). Bounded + fail-open;
// instant when we hold no membership. Skipped when answering (join_existing
// intent never rings) and after a timed-out zombie leave (dead widget — the
// membership can't clear, so don't stack a second black-screen wait).
if (!incoming && !zombieLeaveTimedOut) {
await waitOwnMembershipGone(room, OWN_LEAVE_TIMEOUT_MS);
}
const embed = createCallEmbed( const embed = createCallEmbed(
mx, mx,
room, room,
@ -174,7 +253,7 @@ export const useSwitchOrStartDmCall = (): ((
); );
store.set(callEmbedAtom, embed); store.set(callEmbedAtom, embed);
}, },
[mx, store, callEmbedRef, callPref, theme, waitLeave] [mx, store, callEmbedRef, callPref, theme, waitLeave, waitOwnMembershipGone]
); );
return useCallback( return useCallback(

View file

@ -93,7 +93,10 @@ export const useTelecomConnectionSync = (): void => {
telecomCall.addListener('telecomDisconnect', () => { telecomCall.addListener('telecomDisconnect', () => {
if (store.get(callEmbedAtom) !== callEmbed) return; if (store.get(callEmbedAtom) !== callEmbed) return;
callEmbed.hangup().catch(() => { callEmbed.hangup().catch(() => {
/* widget already gone */ // Widget transport is dead, so no HangupCall event will clear the atom.
// Clear it directly (still-active guard) or the call hangs on with no
// system presence after Telecom already disconnected it (F26).
if (store.get(callEmbedAtom) === callEmbed) store.set(callEmbedAtom, undefined);
}); });
}) })
); );
@ -166,7 +169,9 @@ export const useTelecomConnectionSync = (): void => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn('[telecom] session error, ending call', data.message); console.warn('[telecom] session error, ending call', data.message);
callEmbed.hangup().catch(() => { callEmbed.hangup().catch(() => {
/* widget already gone */ // Widget gone — clear the atom directly so a fatal Telecom error still
// tears the call down instead of leaving it stuck (F26).
if (store.get(callEmbedAtom) === callEmbed) store.set(callEmbedAtom, undefined);
}); });
}) })
); );

View file

@ -16,6 +16,13 @@ export class CallControl extends EventEmitter implements CallControlState {
private controlMutationObserver: MutationObserver; private controlMutationObserver: MutationObserver;
// Last audio-enabled value we SENT to or observed FROM the widget. The mic
// mirror (setMicrophoneEnabled) compares against this, not `this.microphone`
// which only updates on the widget's async echo — two quick mirror calls
// (hold→resume, mute→unmute) before the first echo would both read the same
// stale value and the second no-op, stranding the mic (F30).
private lastAudioEnabled: boolean | undefined;
private get document(): Document | undefined { private get document(): Document | undefined {
return this.iframe.contentDocument ?? this.iframe.contentWindow?.document; return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
} }
@ -117,12 +124,21 @@ export class CallControl extends EventEmitter implements CallControlState {
} }
private setMediaState(state: ElementMediaStatePayload) { private setMediaState(state: ElementMediaStatePayload) {
return this.call.transport.send(ElementWidgetActions.DeviceMute, state); const prevAudio = this.lastAudioEnabled;
if (typeof state.audio_enabled === 'boolean') this.lastAudioEnabled = state.audio_enabled;
return this.call.transport.send(ElementWidgetActions.DeviceMute, state).catch((e) => {
// The widget never applied the change (torn-down iframe) and won't echo it
// back, so roll the optimistic tracker back — otherwise a failed send leaves
// lastAudioEnabled permanently desynced from the real mic (F30 follow-up).
if (typeof state.audio_enabled === 'boolean') this.lastAudioEnabled = prevAudio;
throw e;
});
} }
public onMediaState(evt: CustomEvent<ElementMediaStateDetail>) { public onMediaState(evt: CustomEvent<ElementMediaStateDetail>) {
const { data } = evt.detail; const { data } = evt.detail;
if (!data) return; if (!data) return;
if (typeof data.audio_enabled === 'boolean') this.lastAudioEnabled = data.audio_enabled;
const state = new CallControlState( const state = new CallControlState(
data.audio_enabled ?? this.microphone, data.audio_enabled ?? this.microphone,
@ -158,10 +174,17 @@ export class CallControl extends EventEmitter implements CallControlState {
// what the OS reports. A loop-guard at the call site stops the resulting // what the OS reports. A loop-guard at the call site stops the resulting
// onMediaState echo from bouncing back. // onMediaState echo from bouncing back.
public setMicrophoneEnabled(enabled: boolean): void { public setMicrophoneEnabled(enabled: boolean): void {
if (this.microphone === enabled) return; // Compare against the last value we sent/observed (not `this.microphone`,
// which lags on the widget echo) and drive the widget to the EXPLICIT target
// rather than a toggle. A toggle reads `!this.microphone`, so a second rapid
// mirror call before the echo would compute the wrong direction (F30).
const current = this.lastAudioEnabled ?? this.microphone;
if (current === enabled) return;
// Swallow the transport rejection: if the iframe is torn down mid-call the // Swallow the transport rejection: if the iframe is torn down mid-call the
// send rejects, and this is a best-effort mirror, not user-initiated. // send rejects, and this is a best-effort mirror, not user-initiated.
this.toggleMicrophone().catch(() => undefined); this.setMediaState({ audio_enabled: enabled, video_enabled: this.video }).catch(
() => undefined
);
} }
public toggleVideo() { public toggleVideo() {

View file

@ -22,15 +22,17 @@ import { isAndroidPlatform } from '../../utils/capacitor';
/** Audio routes mirrored to/from CallEndpointCompat types (see VojoCallsManager). */ /** Audio routes mirrored to/from CallEndpointCompat types (see VojoCallsManager). */
export type TelecomRoute = 'EARPIECE' | 'SPEAKER' | 'BLUETOOTH' | 'WIRED' | 'STREAMING' | 'UNKNOWN'; export type TelecomRoute = 'EARPIECE' | 'SPEAKER' | 'BLUETOOTH' | 'WIRED' | 'STREAMING' | 'UNKNOWN';
/** Events emitted by TelecomCallPlugin (see VojoCallsManager.Listener). */ // Events consumed by JS (useTelecomConnectionSync). The native plugin also
// emits telecomAnswer / telecomEndpoints, but nothing on the JS side listens
// (answer-from-killed boots via a PendingIntent, not this event; the speaker
// toggle mirrors only the single active endpoint), so they are not surfaced
// here. See VojoCallsManager.Listener for the full native set.
export type TelecomEvent = export type TelecomEvent =
| 'telecomAnswer'
| 'telecomDisconnect' | 'telecomDisconnect'
| 'telecomSetActive' | 'telecomSetActive'
| 'telecomSetInactive' | 'telecomSetInactive'
| 'telecomMute' | 'telecomMute'
| 'telecomEndpoint' | 'telecomEndpoint'
| 'telecomEndpoints'
| 'telecomError'; | 'telecomError';
export interface TelecomStartOptions { export interface TelecomStartOptions {
@ -43,13 +45,8 @@ export interface TelecomStartOptions {
export interface TelecomCallPlugin { export interface TelecomCallPlugin {
startCall(options: TelecomStartOptions): Promise<void>; startCall(options: TelecomStartOptions): Promise<void>;
answer(options: { roomId: string; video?: boolean }): Promise<void>; answer(options: { roomId: string; video?: boolean }): Promise<void>;
setActive(): Promise<void>;
endCall(): Promise<void>; endCall(): Promise<void>;
requestEndpoint(options: { route: TelecomRoute }): Promise<void>; requestEndpoint(options: { route: TelecomRoute }): Promise<void>;
addListener(
eventName: 'telecomAnswer',
listenerFunc: (data: { video: boolean }) => void
): Promise<PluginListenerHandle>;
addListener( addListener(
eventName: 'telecomDisconnect', eventName: 'telecomDisconnect',
listenerFunc: (data: { causeCode: number }) => void listenerFunc: (data: { causeCode: number }) => void
@ -66,10 +63,6 @@ export interface TelecomCallPlugin {
eventName: 'telecomEndpoint', eventName: 'telecomEndpoint',
listenerFunc: (data: { route: TelecomRoute }) => void listenerFunc: (data: { route: TelecomRoute }) => void
): Promise<PluginListenerHandle>; ): Promise<PluginListenerHandle>;
addListener(
eventName: 'telecomEndpoints',
listenerFunc: (data: { routes: TelecomRoute[] }) => void
): Promise<PluginListenerHandle>;
addListener( addListener(
eventName: 'telecomError', eventName: 'telecomError',
listenerFunc: (data: { message: string }) => void listenerFunc: (data: { message: string }) => void
@ -97,10 +90,6 @@ export const telecomCall = {
if (!isTelecomEnabled()) return Promise.resolve(); if (!isTelecomEnabled()) return Promise.resolve();
return plugin.answer({ roomId, video }); return plugin.answer({ roomId, video });
}, },
setActive(): Promise<void> {
if (!isTelecomEnabled()) return Promise.resolve();
return plugin.setActive();
},
// Idempotent best-effort teardown — safe to call on any platform. // Idempotent best-effort teardown — safe to call on any platform.
endCall(): Promise<void> { endCall(): Promise<void> {
if (!isTelecomEnabled()) return Promise.resolve(); if (!isTelecomEnabled()) return Promise.resolve();

View file

@ -1,5 +1,5 @@
import { registerPlugin } from '@capacitor/core'; import { registerPlugin } from '@capacitor/core';
import { isNativePlatform } from '../utils/capacitor'; import { isAndroidPlatform } from '../utils/capacitor';
// Bridge to the native NotificationPolicyPlugin (see // Bridge to the native NotificationPolicyPlugin (see
// android/app/src/main/java/chat/vojo/app/NotificationPolicyPlugin.java). // android/app/src/main/java/chat/vojo/app/NotificationPolicyPlugin.java).
@ -19,7 +19,7 @@ const NotificationPolicy = registerPlugin<NotificationPolicyPlugin>('Notificatio
}); });
export const canBypassDnd = async (): Promise<boolean> => { export const canBypassDnd = async (): Promise<boolean> => {
if (!isNativePlatform()) return true; if (!isAndroidPlatform()) return true;
try { try {
const { value } = await NotificationPolicy.canBypassDnd(); const { value } = await NotificationPolicy.canBypassDnd();
return value; return value;
@ -32,6 +32,6 @@ export const canBypassDnd = async (): Promise<boolean> => {
}; };
export const openNotificationPolicySettings = async (): Promise<void> => { export const openNotificationPolicySettings = async (): Promise<void> => {
if (!isNativePlatform()) return; if (!isAndroidPlatform()) return;
await NotificationPolicy.openSettings(); await NotificationPolicy.openSettings();
}; };

View file

@ -11,6 +11,13 @@ export const RTC_NOTIFICATION_DEFAULT_LIFETIME = 30_000;
// (CallWidgetDriver `RTC_RING_LIFETIME_MAX_MS`). // (CallWidgetDriver `RTC_RING_LIFETIME_MAX_MS`).
export const RTC_NOTIFICATION_MAX_LIFETIME = 5 * 60 * 1000; export const RTC_NOTIFICATION_MAX_LIFETIME = 5 * 60 * 1000;
// Grace before withdrawing a ring (or tearing a call down) when the peer's RTC
// membership goes empty — absorbs brief LiveKit-reconnect membership flaps so a
// hiccup doesn't drop a still-live ring/call. Shared by useIncomingRtcNotifications
// (caller-cancel withdraw) and useCallerAutoHangup (peer-left teardown); the two
// MUST move together, so they live here (F41).
export const RTC_PEER_LEAVE_GRACE_MS = 8_000;
// Resolve a ring's lifetime from event content, clamped to a sane range. A // Resolve a ring's lifetime from event content, clamped to a sane range. A
// missing / non-finite / non-positive value falls back to the default; anything // missing / non-finite / non-positive value falls back to the default; anything
// larger than the cap is clamped down. // larger than the cap is clamped down.

View file

@ -787,22 +787,41 @@ self.addEventListener('push', (event: PushEvent) => {
// the caller cancels or it times out. Hold the SW alive for the bounded // the caller cancels or it times out. Hold the SW alive for the bounded
// ring window and close the banner at expiry. A decline / answer / // ring window and close the banner at expiry. A decline / answer /
// caller-cancel seen by a live client closes it sooner via 'closeCall'. // caller-cancel seen by a live client closes it sooner via 'closeCall'.
// Ring-expiry self-dismiss window. DEFAULT/MAX mirror the client caps in
// utils/rtcNotification.ts (RTC_NOTIFICATION_DEFAULT/MAX_LIFETIME); MAX is
// the hard ceiling on how long the SW keeps a banner (and itself) alive.
const DEFAULT_RING_MS = 30_000; const DEFAULT_RING_MS = 30_000;
const MAX_RING_MS = 95_000; const MAX_RING_MS = 95_000;
// Trust the sender_ts anchor only within this clock-skew tolerance; past
// it, fall back to receive-time so a bad clock can't stretch the banner.
const SENDER_TS_SKEW_TOLERANCE_MS = 15_000;
// Small grace past nominal expiry before withdrawing the banner.
const RING_EXPIRY_GRACE_MS = 2_000;
const life = const life =
typeof callLifetime === 'number' && callLifetime > 0 typeof callLifetime === 'number' && callLifetime > 0
? Math.min(callLifetime, MAX_RING_MS) ? Math.min(callLifetime, MAX_RING_MS)
: DEFAULT_RING_MS; : DEFAULT_RING_MS;
const base = const base =
typeof callSenderTs === 'number' && Math.abs(callSenderTs - Date.now()) < 15_000 typeof callSenderTs === 'number' &&
Math.abs(callSenderTs - Date.now()) < SENDER_TS_SKEW_TOLERANCE_MS
? callSenderTs ? callSenderTs
: Date.now(); : Date.now();
const delay = Math.min(MAX_RING_MS, Math.max(0, base + life + 2_000 - Date.now())); const delay = Math.min(
MAX_RING_MS,
Math.max(0, base + life + RING_EXPIRY_GRACE_MS - Date.now())
);
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
setTimeout(resolve, delay); setTimeout(resolve, delay);
}); });
// Close only the banner THIS timer was scheduled for. A newer recall for
// the same room reuses the `call_${roomId}` tag but carries a different
// eventId; the first ring's expiry must not close the newer, still-live
// banner (F20). The `closeCall` message handler stays room-wide on
// purpose (an answer/decline/cancel withdraws every ring for the room).
const openRings = await self.registration.getNotifications({ tag: `call_${roomId}` }); const openRings = await self.registration.getNotifications({ tag: `call_${roomId}` });
openRings.forEach((n) => n.close()); openRings
.filter((n) => (n.data as { eventId?: string } | undefined)?.eventId === eventId)
.forEach((n) => n.close());
return; return;
} }