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:
parent
b88e056dcd
commit
64517a73f7
11 changed files with 329 additions and 101 deletions
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
25
src/sw.ts
25
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
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue