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()