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.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicLong
@ -178,6 +177,13 @@ class VojoCallsManager private constructor(context: Context) {
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. */
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.
callsManager.registerAppWithTelecom(CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING)
registered = true
Log.d(TAG, "registerAppWithTelecom ok")
dlog("registerAppWithTelecom ok")
} catch (t: Throwable) {
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
// just-answered ring. So we no longer gate on controlScope != null.
if (currentRoomId == roomId) {
Log.d(TAG, "startCall: session already live/forming for room=$roomId")
// Ensure an outgoing/join-time call goes active once the scope
// exists; if it isn't published yet the addCall block (outgoing)
// or pendingAnswer (incoming) will activate it.
if (!incoming && controlScope != null) setCallActive()
dlog("startCall: session already live/forming for room=$roomId")
// A JS outgoing/join for the SAME room as a ring-created incoming
// session (answered=false) means the ring has been consumed by the
// user joining — mark it answered so a later removeIncomingRing for
// 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
}
// 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
// if this session was superseded (stale generation).
{ callType ->
Log.d(TAG, "onAnswer callType=$callType")
dlog("onAnswer callType=$callType")
if (myGen == sessionGen.get()) {
answered = true
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
// tear down the NEW call.
{ cause ->
Log.d(TAG, "onDisconnect cause=${cause.code}")
dlog("onDisconnect cause=${cause.code}")
if (myGen == sessionGen.get()) {
clearSessionState(myGen)
notifyLifecycle { it.onDisconnect(cause.code) }
@ -307,7 +328,7 @@ class VojoCallsManager private constructor(context: Context) {
},
// onSetActive (resume from hold).
{
Log.d(TAG, "onSetActive")
dlog("onSetActive")
if (myGen == sessionGen.get()) {
answered = true
activeListener()?.onSetActive()
@ -315,7 +336,7 @@ class VojoCallsManager private constructor(context: Context) {
},
// onSetInactive (hold, e.g. interrupting cellular call).
{
Log.d(TAG, "onSetInactive")
dlog("onSetInactive")
if (myGen == sessionGen.get()) activeListener()?.onSetInactive()
},
) {
@ -331,7 +352,7 @@ class VojoCallsManager private constructor(context: Context) {
if (!superseded) controlScope = this
}
if (superseded) {
Log.d(TAG, "addCall accepted but superseded — self-disconnecting gen=$myGen")
dlog("addCall accepted but superseded — self-disconnecting gen=$myGen")
launch {
try {
disconnect(DisconnectCause(DisconnectCause.LOCAL))
@ -340,9 +361,9 @@ class VojoCallsManager private constructor(context: Context) {
}
}
} else {
Log.d(TAG, "addCall accepted: session live (incoming=$incoming) gen=$myGen")
dlog("addCall accepted: session live (incoming=$incoming) gen=$myGen")
if (!incoming) {
launch { Log.d(TAG, "setActive -> ${setActive()}") }
launch { dlog("setActive -> ${setActive()}") }
} else {
// A UI answer that landed before the session was live —
// 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.
launch {
currentCallEndpoint.collect { ep ->
Log.d(TAG, "endpoint -> ${typeToRoute(ep.type)}")
dlog("endpoint -> ${typeToRoute(ep.type)}")
if (myGen == sessionGen.get()) {
activeListener()?.onEndpointChanged(typeToRoute(ep.type))
}
@ -390,7 +411,7 @@ class VojoCallsManager private constructor(context: Context) {
}
launch {
isMuted.collect { muted ->
Log.d(TAG, "mute -> $muted")
dlog("mute -> $muted")
if (myGen == sessionGen.get()) {
activeListener()?.onMuteStateChanged(muted)
}
@ -423,12 +444,24 @@ class VojoCallsManager private constructor(context: Context) {
fun answerCall(roomId: String, video: Boolean) {
val cs: CallControlScope?
synchronized(lock) {
// Room-aware: only the ring that actually owns the session can be
// answered. currentRoomId is set synchronously by startCall even
// before the session goes live, so this is reliable during the
// answer-from-killed window.
if (currentRoomId != null && currentRoomId != roomId) {
Log.w(TAG, "answerCall: room mismatch (live=$currentRoomId), ignoring")
// Require a live/forming session for THIS room. currentRoomId is set
// synchronously by startCall even before the session goes live, so this
// stays reliable during the answer-from-killed window. The null case
// matters too: after a Telecom register/addCall failure the ring degrades
// to notification-only (F11) and currentRoomId is cleared — an answer for
// 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
}
// Flip out of RINGING synchronously and unconditionally so a
@ -499,7 +532,7 @@ class VojoCallsManager private constructor(context: Context) {
cs.launch {
try {
val result = cs.requestEndpointChange(target)
Log.d(TAG, "requestEndpoint $route -> $result")
dlog("requestEndpoint $route -> $result")
} catch (t: Throwable) {
Log.w(TAG, "requestEndpoint failed", t)
}
@ -524,7 +557,7 @@ class VojoCallsManager private constructor(context: Context) {
pendingAnswerVideo = false
availableEndpointList = emptyList()
if (cs == null) return
Log.d(TAG, "teardown: disconnect cause=$causeCode")
dlog("teardown: disconnect cause=$causeCode")
cs.launch {
try {
cs.disconnect(DisconnectCause(causeCode))

View file

@ -3,6 +3,7 @@ import { MatrixClient, Room } from 'matrix-js-sdk';
import { useSetAtom } from 'jotai';
import {
CallEmbed,
ElementCallIntent,
ElementCallThemeKind,
ElementWidgetActions,
useClientWidgetApiEvent,
@ -45,9 +46,34 @@ export const createCallEmbed = (
incoming = false
): CallEmbed => {
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 controlState =
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 => {
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(
embed?.call,
@ -90,12 +129,6 @@ export const useCallJoined = (embed?: CallEmbed): boolean => {
}, [])
);
useEffect(() => {
if (!embed) {
setJoined(false);
}
}, [embed]);
return joined;
};

View file

@ -40,17 +40,13 @@ import {
getNotificationEventSendTs,
getRtcNotificationLifetime,
RTC_NOTIFICATION_DEFAULT_LIFETIME,
RTC_PEER_LEAVE_GRACE_MS,
} from '../utils/rtcNotification';
// Grace beyond the ring lifetime before giving up on no-answer. Covers /sync
// latency between B joining and A seeing the membership state event.
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 = (
selfId: string,
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
// with a live mic (RED-7). A late /sync membership still cancels this timer
// via onMemberships before it fires.
const noAnswerTimer: ReturnType<typeof setTimeout> | undefined = peerSeen
? undefined
: setTimeout(
let noAnswerTimer: ReturnType<typeof setTimeout> | undefined;
const clearNoAnswerTimer = () => {
if (noAnswerTimer) {
clearTimeout(noAnswerTimer);
noAnswerTimer = undefined;
}
};
const armNoAnswerTimer = () => {
clearNoAnswerTimer();
noAnswerTimer = setTimeout(
performHangup,
callEmbed.incoming ? PEER_LEAVE_GRACE_MS : computeNoAnswerDelay()
callEmbed.incoming ? RTC_PEER_LEAVE_GRACE_MS : computeNoAnswerDelay()
);
};
if (!peerSeen) armNoAnswerTimer();
const onMemberships = (_prev: CallMembership[], next: CallMembership[]) => {
const peerPresent = next.some(isPeer);
@ -153,12 +158,12 @@ export const useCallerAutoHangup = (): void => {
clearPeerLeaveTimer();
if (!peerSeen) {
peerSeen = true;
if (noAnswerTimer) clearTimeout(noAnswerTimer);
clearNoAnswerTimer();
}
return;
}
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>();
if (content.notification_type !== 'ring') return;
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()) {
performHangup();
}
@ -237,7 +252,7 @@ export const useCallerAutoHangup = (): void => {
return () => {
disposed = true;
if (noAnswerTimer) clearTimeout(noAnswerTimer);
clearNoAnswerTimer();
clearPeerLeaveTimer();
session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
mx.removeListener(RoomEvent.Timeline, onTimeline);

View file

@ -40,15 +40,11 @@ import {
getNotificationEventSendTs,
getRtcNotificationLifetime,
isRtcNotificationExpired,
RTC_NOTIFICATION_DEFAULT_LIFETIME,
RTC_NOTIFICATION_MAX_LIFETIME,
RTC_PEER_LEAVE_GRACE_MS,
} from '../utils/rtcNotification';
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.
// 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
@ -181,6 +177,12 @@ export const useIncomingRtcNotifications = (): void => {
const mDirectRef = useRef(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());
// Notification IDs whose RTCDecline already arrived — stops a late-decrypted
@ -300,7 +302,7 @@ export const useIncomingRtcNotifications = (): void => {
setTimeout(() => {
peerLeaveTimers.delete(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 existing = declinedTimers.get(notifEventId);
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(() => {
declinedTimers.delete(notifEventId);
}, RTC_NOTIFICATION_DEFAULT_LIFETIME);
}, RTC_NOTIFICATION_MAX_LIFETIME);
declinedTimers.set(notifEventId, timer);
};
@ -367,6 +373,10 @@ export const useIncomingRtcNotifications = (): void => {
/* 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>();
@ -385,9 +395,24 @@ export const useIncomingRtcNotifications = (): void => {
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);
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();
return;
}
@ -412,9 +437,9 @@ export const useIncomingRtcNotifications = (): void => {
// Re-check anything that can change during the await. resolveCallId can
// yield for seconds (5s MembershipsChanged wait, then fetchRoomEvent) —
// a membership join or a matching decline can land meanwhile and must be
// observed before we commit the ADD.
if (session.memberships.some((m) => m.userId === mx.getUserId())) {
// a genuine join or a matching decline can land meanwhile and must be
// observed before we commit the ADD (same predicate as the pre-await check).
if (genuinelyParticipating()) {
removeFromRegistry();
return;
}

View file

@ -24,6 +24,7 @@
// makes the second tap a noop, matching user intent.
import { useCallback, useRef } from 'react';
import { Room } from 'matrix-js-sdk';
import { useAtomValue, useStore } from 'jotai';
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
@ -35,6 +36,16 @@ import { callEmbedAtom } from '../state/callEmbed';
import { CallEmbed } from '../plugins/call';
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 =>
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 waitLeave = useCallback(
(prev: CallEmbed): Promise<void> => {
(prev: CallEmbed, timeoutMs: number = SWITCH_LEAVE_TIMEOUT_MS): Promise<void> => {
const session = mx.matrixRTC.getRoomSession(prev.room);
const selfUserId = mx.getSafeUserId();
const selfDeviceId = mx.getDeviceId();
@ -87,7 +98,7 @@ export const useSwitchOrStartDmCall = (): ((
session.on(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
prev.call.on('action:im.vector.hangup', onHangupAction);
const timer = setTimeout(fail, SWITCH_LEAVE_TIMEOUT_MS);
const timer = setTimeout(fail, timeoutMs);
cleanup = () => {
session.off(MatrixRTCSessionEvent.MembershipsChanged, onMemberships);
@ -104,6 +115,47 @@ export const useSwitchOrStartDmCall = (): ((
[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(
async (roomId: string, incoming: boolean): Promise<void> => {
const room = mx.getRoom(roomId);
@ -115,14 +167,16 @@ export const useSwitchOrStartDmCall = (): ((
const prev = store.get(callEmbedAtom);
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 selfDeviceId = mx.getDeviceId();
const peerPresent = mx.matrixRTC
.getRoomSession(prev.room)
.memberships.some(
(m: CallMembership) =>
m.userId !== selfUserId || (!!selfDeviceId && m.deviceId !== selfDeviceId)
);
.memberships.some((m: CallMembership) => m.userId !== selfUserId);
if (prev.joined && peerPresent) return;
}
@ -131,16 +185,31 @@ export const useSwitchOrStartDmCall = (): ((
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.roomId === roomId) {
// Zombie same-room embed (widget never joined, peer gone, or stuck
// after LiveKit reconnect fail). waitLeave would hang on the
// unresponsive widget. Best-effort widget hangup before direct
// dispose so the widget's MembershipManager gets a chance to send
// the server-side leave event; if it doesn't, the SDK's delayed
// leave fires a few seconds after dispose.
// after LiveKit reconnect fail). Give a JOINED widget a bounded chance
// to send its server-side leave before disposing: waitLeave arms the
// membership/hangup barrier, then hangs up. If the widget responds our
// membership clears (so the re-dial rings — F1/F2); if it's
// 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) {
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();
} 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(
mx,
room,
@ -174,7 +253,7 @@ export const useSwitchOrStartDmCall = (): ((
);
store.set(callEmbedAtom, embed);
},
[mx, store, callEmbedRef, callPref, theme, waitLeave]
[mx, store, callEmbedRef, callPref, theme, waitLeave, waitOwnMembershipGone]
);
return useCallback(

View file

@ -93,7 +93,10 @@ export const useTelecomConnectionSync = (): void => {
telecomCall.addListener('telecomDisconnect', () => {
if (store.get(callEmbedAtom) !== callEmbed) return;
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
console.warn('[telecom] session error, ending call', data.message);
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;
// 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 {
return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
}
@ -117,12 +124,21 @@ export class CallControl extends EventEmitter implements CallControlState {
}
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>) {
const { data } = evt.detail;
if (!data) return;
if (typeof data.audio_enabled === 'boolean') this.lastAudioEnabled = data.audio_enabled;
const state = new CallControlState(
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
// onMediaState echo from bouncing back.
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
// 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() {

View file

@ -22,15 +22,17 @@ import { isAndroidPlatform } from '../../utils/capacitor';
/** Audio routes mirrored to/from CallEndpointCompat types (see VojoCallsManager). */
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 =
| 'telecomAnswer'
| 'telecomDisconnect'
| 'telecomSetActive'
| 'telecomSetInactive'
| 'telecomMute'
| 'telecomEndpoint'
| 'telecomEndpoints'
| 'telecomError';
export interface TelecomStartOptions {
@ -43,13 +45,8 @@ export interface TelecomStartOptions {
export interface TelecomCallPlugin {
startCall(options: TelecomStartOptions): Promise<void>;
answer(options: { roomId: string; video?: boolean }): Promise<void>;
setActive(): Promise<void>;
endCall(): Promise<void>;
requestEndpoint(options: { route: TelecomRoute }): Promise<void>;
addListener(
eventName: 'telecomAnswer',
listenerFunc: (data: { video: boolean }) => void
): Promise<PluginListenerHandle>;
addListener(
eventName: 'telecomDisconnect',
listenerFunc: (data: { causeCode: number }) => void
@ -66,10 +63,6 @@ export interface TelecomCallPlugin {
eventName: 'telecomEndpoint',
listenerFunc: (data: { route: TelecomRoute }) => void
): Promise<PluginListenerHandle>;
addListener(
eventName: 'telecomEndpoints',
listenerFunc: (data: { routes: TelecomRoute[] }) => void
): Promise<PluginListenerHandle>;
addListener(
eventName: 'telecomError',
listenerFunc: (data: { message: string }) => void
@ -97,10 +90,6 @@ export const telecomCall = {
if (!isTelecomEnabled()) return Promise.resolve();
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.
endCall(): Promise<void> {
if (!isTelecomEnabled()) return Promise.resolve();

View file

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

View file

@ -11,6 +11,13 @@ export const RTC_NOTIFICATION_DEFAULT_LIFETIME = 30_000;
// (CallWidgetDriver `RTC_RING_LIFETIME_MAX_MS`).
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
// missing / non-finite / non-positive value falls back to the default; anything
// 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
// ring window and close the banner at expiry. A decline / answer /
// 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 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 =
typeof callLifetime === 'number' && callLifetime > 0
? Math.min(callLifetime, MAX_RING_MS)
: DEFAULT_RING_MS;
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
: 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) => {
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}` });
openRings.forEach((n) => n.close());
openRings
.filter((n) => (n.data as { eventId?: string } | undefined)?.eventId === eventId)
.forEach((n) => n.close());
return;
}