From 57312753629d95082a083e12aae54b6fb407dc8c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 20:15:26 +0000 Subject: [PATCH] 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 --- .../amethyst/commons/call/CallManager.kt | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index 2ff076835..24266dcd6 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -71,6 +71,17 @@ class CallManager( private var resetJob: Job? = null private val processedEventIds = mutableSetOf() + /** 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() + + /** 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() @@ -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, reason: EndReason, ) { + completedCallIds.add(callId) _state.value = CallState.Ended(callId, peerPubKeys, reason) cancelTimeout() resetJob?.cancel()