From 64517a73f755d6391cadb7abc95d0a9f768cbcbf Mon Sep 17 00:00:00 2001 From: heaven Date: Sat, 4 Jul 2026 02:06:31 +0300 Subject: [PATCH] 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. --- .../java/chat/vojo/app/VojoCallsManager.kt | 83 ++++++++++---- src/app/hooks/useCallEmbed.ts | 49 ++++++-- src/app/hooks/useCallerAutoHangup.ts | 43 ++++--- src/app/hooks/useIncomingRtcNotifications.ts | 51 ++++++--- src/app/hooks/useSwitchOrStartDmCall.ts | 107 +++++++++++++++--- src/app/hooks/useTelecomConnectionSync.ts | 9 +- src/app/plugins/call/CallControl.ts | 29 ++++- src/app/plugins/call/telecomCall.ts | 21 +--- src/app/plugins/notificationPolicy.ts | 6 +- src/app/utils/rtcNotification.ts | 7 ++ src/sw.ts | 25 +++- 11 files changed, 329 insertions(+), 101 deletions(-) diff --git a/android/app/src/main/java/chat/vojo/app/VojoCallsManager.kt b/android/app/src/main/java/chat/vojo/app/VojoCallsManager.kt index c095b1eb..ebd52928 100644 --- a/android/app/src/main/java/chat/vojo/app/VojoCallsManager.kt +++ b/android/app/src/main/java/chat/vojo/app/VojoCallsManager.kt @@ -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)) diff --git a/src/app/hooks/useCallEmbed.ts b/src/app/hooks/useCallEmbed.ts index 5ae10865..f8bf4670 100644 --- a/src/app/hooks/useCallEmbed.ts +++ b/src/app/hooks/useCallEmbed.ts @@ -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; }; diff --git a/src/app/hooks/useCallerAutoHangup.ts b/src/app/hooks/useCallerAutoHangup.ts index f0f97414..43e59f87 100644 --- a/src/app/hooks/useCallerAutoHangup.ts +++ b/src/app/hooks/useCallerAutoHangup.ts @@ -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 | undefined = peerSeen - ? undefined - : setTimeout( - performHangup, - callEmbed.incoming ? PEER_LEAVE_GRACE_MS : computeNoAnswerDelay() - ); + let noAnswerTimer: ReturnType | undefined; + const clearNoAnswerTimer = () => { + if (noAnswerTimer) { + clearTimeout(noAnswerTimer); + 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 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(); 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); diff --git a/src/app/hooks/useIncomingRtcNotifications.ts b/src/app/hooks/useIncomingRtcNotifications.ts index bc0a8e7c..b471336e 100644 --- a/src/app/hooks/useIncomingRtcNotifications.ts +++ b/src/app/hooks/useIncomingRtcNotifications.ts @@ -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>(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(); @@ -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; } diff --git a/src/app/hooks/useSwitchOrStartDmCall.ts b/src/app/hooks/useSwitchOrStartDmCall.ts index 29171b9d..ec1032af 100644 --- a/src/app/hooks/useSwitchOrStartDmCall.ts +++ b/src/app/hooks/useSwitchOrStartDmCall.ts @@ -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 | undefined>(undefined); const waitLeave = useCallback( - (prev: CallEmbed): Promise => { + (prev: CallEmbed, timeoutMs: number = SWITCH_LEAVE_TIMEOUT_MS): Promise => { 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 => { + 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((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 => { 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( diff --git a/src/app/hooks/useTelecomConnectionSync.ts b/src/app/hooks/useTelecomConnectionSync.ts index d6074b32..86917952 100644 --- a/src/app/hooks/useTelecomConnectionSync.ts +++ b/src/app/hooks/useTelecomConnectionSync.ts @@ -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); }); }) ); diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index 4f474d94..0763b1e1 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -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) { 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() { diff --git a/src/app/plugins/call/telecomCall.ts b/src/app/plugins/call/telecomCall.ts index b1d389e4..21ed70de 100644 --- a/src/app/plugins/call/telecomCall.ts +++ b/src/app/plugins/call/telecomCall.ts @@ -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; answer(options: { roomId: string; video?: boolean }): Promise; - setActive(): Promise; endCall(): Promise; requestEndpoint(options: { route: TelecomRoute }): Promise; - addListener( - eventName: 'telecomAnswer', - listenerFunc: (data: { video: boolean }) => void - ): Promise; addListener( eventName: 'telecomDisconnect', listenerFunc: (data: { causeCode: number }) => void @@ -66,10 +63,6 @@ export interface TelecomCallPlugin { eventName: 'telecomEndpoint', listenerFunc: (data: { route: TelecomRoute }) => void ): Promise; - addListener( - eventName: 'telecomEndpoints', - listenerFunc: (data: { routes: TelecomRoute[] }) => void - ): Promise; 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 { - if (!isTelecomEnabled()) return Promise.resolve(); - return plugin.setActive(); - }, // Idempotent best-effort teardown — safe to call on any platform. endCall(): Promise { if (!isTelecomEnabled()) return Promise.resolve(); diff --git a/src/app/plugins/notificationPolicy.ts b/src/app/plugins/notificationPolicy.ts index 57120da6..7a16f293 100644 --- a/src/app/plugins/notificationPolicy.ts +++ b/src/app/plugins/notificationPolicy.ts @@ -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('Notificatio }); export const canBypassDnd = async (): Promise => { - 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 => { }; export const openNotificationPolicySettings = async (): Promise => { - if (!isNativePlatform()) return; + if (!isAndroidPlatform()) return; await NotificationPolicy.openSettings(); }; diff --git a/src/app/utils/rtcNotification.ts b/src/app/utils/rtcNotification.ts index 100b4c20..c6d9e2e2 100644 --- a/src/app/utils/rtcNotification.ts +++ b/src/app/utils/rtcNotification.ts @@ -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. diff --git a/src/sw.ts b/src/sw.ts index 626372f3..aa48f719 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -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((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; }