Merge pull request #2164 from vitorpamplona/claude/fix-old-call-ringing-nM8y5

Prevent stale call offers from retriggering after app restart
This commit is contained in:
Vitor Pamplona
2026-04-06 16:21:36 -04:00
committed by GitHub
@@ -71,6 +71,17 @@ class CallManager(
private var resetJob: Job? = null
private val processedEventIds = mutableSetOf<String>()
/** Call IDs for which we have seen a hangup, reject, or answer-elsewhere
* signal. Checked before transitioning to [CallState.IncomingCall] so
* that stale offer events replayed by relays after an app restart do not
* trigger ringing for calls that already ended. */
private val completedCallIds = mutableSetOf<String>()
/** Timestamp (epoch seconds) when this CallManager was created. Events
* created before this are from a previous app session and should not
* trigger ringing. */
private val initTimestamp = TimeUtils.now()
/** Peers whose answers we saw while still ringing (IncomingCall).
* After we accept, we trigger callee-to-callee mesh setup with them. */
private val discoveredCalleePeers = mutableSetOf<HexKey>()
@@ -81,7 +92,16 @@ class CallManager(
const val MAX_EVENT_AGE_SECONDS = 20L // discard signaling events older than this
}
private fun isEventTooOld(event: Event): Boolean = TimeUtils.now() - event.createdAt > MAX_EVENT_AGE_SECONDS
private fun isEventTooOld(event: Event): Boolean {
val age = TimeUtils.now() - event.createdAt
if (age > MAX_EVENT_AGE_SECONDS) return true
// Reject events created before this CallManager was initialized.
// After an app restart the relay replays old events; even if the
// wall-clock age is within the window, events from a previous
// session should never start ringing.
if (event.createdAt < initTimestamp - MAX_EVENT_AGE_SECONDS) return true
return false
}
// ---- Call initiation (state + publish) ----
@@ -174,6 +194,11 @@ class CallManager(
return
}
if (callId in completedCallIds) {
Log.d("CallManager") { "onIncomingCallEvent: callId=$callId already completed — ignoring stale offer" }
return
}
val currentState = _state.value
// Mid-call offer: same call-id, we're already in the call
@@ -607,6 +632,21 @@ class CallManager(
Log.d("CallManager") { "Processing signaling event kind=${event.kind} id=${event.id.take(8)} state=${_state.value::class.simpleName}" }
// Record call-ids from termination signals so that a later offer
// for the same call is recognised as stale (common after app restart
// when relay replays events out of order).
if (event is CallHangupEvent || event is CallRejectEvent) {
val terminatedCallId =
when (event) {
is CallHangupEvent -> event.callId()
is CallRejectEvent -> event.callId()
else -> null
}
if (terminatedCallId != null) {
completedCallIds.add(terminatedCallId)
}
}
when (event) {
is CallOfferEvent -> onIncomingCallEvent(event)
is CallAnswerEvent -> onCallAnswered(event)
@@ -655,6 +695,8 @@ class CallManager(
resetJob = null
processedEventIds.clear()
discoveredCalleePeers.clear()
// Note: completedCallIds is intentionally NOT cleared here so that
// stale offers for previously-ended calls remain blocked.
}
private fun transitionToEnded(
@@ -662,6 +704,7 @@ class CallManager(
peerPubKeys: Set<HexKey>,
reason: EndReason,
) {
completedCallIds.add(callId)
_state.value = CallState.Ended(callId, peerPubKeys, reason)
cancelTimeout()
resetJob?.cancel()