fix: prevent old call events from triggering ringing after app restart

When the app restarts and reconnects to relays, old call offer events
from completed calls could be replayed, causing the phone to ring for
calls that already ended. This was especially noticeable when the user
killed and restarted the app shortly after a call.

Three protections added to CallManager:

1. Track completed call IDs: hangup/reject events mark their call-id as
   completed. Subsequent offer events for the same call-id are ignored.

2. Init timestamp guard: events created before the CallManager was
   initialized (minus a grace period) are rejected, preventing stale
   events from a previous app session from triggering ringing.

3. Completed call IDs survive reset(): the set is intentionally not
   cleared when the call state machine resets to Idle, ensuring that
   stale offers remain blocked for the lifetime of the CallManager.

https://claude.ai/code/session_0145VKiG8yZMqcMsaBEjNPxv
This commit is contained in:
Claude
2026-04-06 20:15:26 +00:00
parent 96fd58dfcd
commit 5731275362
@@ -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()