fix: CallActivity owns the entire call session lifecycle

Root cause: WebRTC call ringing continued forever after cancellation
because ringing, WebRTC, and notifications were owned by CallController
which lived in viewModelScope (account lifetime), not by CallActivity.
Cleanup depended on a state collector observing Ended in time, which
raced, dropped, and went stale.

Architecture after fix:
- CallManager (background, AccountViewModel scope): state machine only.
  No audio, no WebRTC, no notifications. Exposes state as StateFlow,
  renegotiation events as SharedFlow, and a 65-second ringing watchdog
  as a fail-safe.
- CallSession (Activity-scoped, replaces CallController): owns all call
  resources. Created in onCreate, destroyed via close() in onDestroy.
  Implements AutoCloseable. No cleanedUp guard flag needed.
- CallSessionBridge: slimmed down — holds only callManager +
  accountViewModel for cross-Activity reference sharing. No
  callController field.

Key changes:
- CallController renamed to CallSession, moved to ui/call/session/.
- CallSession.close() replaces cleanup() — single-shot, no guard flags.
- CallActivity creates and owns CallSession directly.
- CallManager.onRenegotiationOfferReceived callback replaced with
  renegotiationEvents SharedFlow (buffered, survives Activity restarts).
- CallManager gains ringing watchdog (65s) that forces Ended(TIMEOUT)
  if state stays in IncomingCall/Offering past the deadline.
- CallNotifier extracted from NotificationUtils into dedicated class.
- AccountViewModel.initCallController deleted; callManager wired to
  newNotesPreProcessor eagerly in init block.
- ChatroomScreen uses CallActivity.launchForOutgoingCall() with intent
  extras instead of directly calling callController.initiateGroupCall().
- AndroidManifest adds navigation|fontScale|density to configChanges
  so no config change recreates CallActivity mid-call.

Invariants:
1. CallActivity.onDestroy → session.close() → all resources released.
2. Ringing cannot outlive any code path: Activity close, state
   collector, and 65s watchdog provide three independent stop paths.
3. Config changes cannot kill the call (manifest prevents recreation).
4. No global mutable singleton for CallController.

https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
This commit is contained in:
Claude
2026-04-16 05:44:52 +00:00
parent 8466a0c0d5
commit 3cddda6ef9
15 changed files with 525 additions and 429 deletions
@@ -35,10 +35,16 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
@@ -73,7 +79,16 @@ class CallManager(
/** Called for every answer that should be applied to a PeerConnection (includes peer pubkey). */
var onAnswerReceived: ((CallAnswerEvent) -> Unit)? = null
var onIceCandidateReceived: ((CallIceCandidateEvent) -> Unit)? = null
var onRenegotiationOfferReceived: ((CallRenegotiateEvent) -> Unit)? = null
/**
* Renegotiation offers from remote peers are emitted as flow events so the
* Activity-scoped `CallSession` can (re)subscribe via `repeatOnLifecycle`
* without losing messages across Activity restarts. Buffered so a short
* gap between Activity teardown and re-collection cannot drop a
* renegotiation.
*/
private val _renegotiationEvents = MutableSharedFlow<CallRenegotiateEvent>(extraBufferCapacity = 8)
val renegotiationEvents: SharedFlow<CallRenegotiateEvent> = _renegotiationEvents.asSharedFlow()
/** Called when a new peer joins the group call (callee-to-callee mesh setup). */
var onNewPeerInGroupCall: ((peerPubKey: HexKey) -> Unit)? = null
@@ -88,6 +103,17 @@ class CallManager(
private var resetJob: Job? = null
private val processedEventIds = LinkedHashSet<String>()
/**
* Detached scope for the ringing watchdog so the timer survives the
* `scope` being cancelled by the owning ViewModel. The watchdog is a
* fail-safe that unconditionally forces an `Ended(TIMEOUT)` transition
* if the state stays in `IncomingCall`/`Offering` past
* `RINGING_WATCHDOG_MS`, regardless of whether any collector observes
* the state. Cancelled explicitly via [dispose].
*/
private val watchdogScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var ringingWatchdogJob: Job? = null
/** Per-peer invite timeout jobs. A separate 30-second timer is scheduled
* for each peer we are waiting on (initial group-call offerees and
* mid-call invitees) so that slow/unavailable peers can be dropped
@@ -130,6 +156,16 @@ class CallManager(
const val MAX_EVENT_AGE_SECONDS = 20L // discard signaling events older than this
const val MAX_PROCESSED_EVENT_IDS = 2_000 // cap dedup set to prevent unbounded growth
const val MAX_COMPLETED_CALL_IDS = 200 // cap completed-call set
/**
* Hard ceiling on how long a call may remain in a ringing state
* (`IncomingCall` or `Offering`). Deliberately longer than
* [CALL_TIMEOUT_MS] (60s) so the normal timeout path gets a chance
* to fire first; this is purely the fail-safe for when that path
* never runs (e.g. a stuck coroutine, a canceled `scope`, or a
* dropped state transition).
*/
const val RINGING_WATCHDOG_MS = 65_000L
}
/** Adds [value] to a [LinkedHashSet], evicting the oldest entries when
@@ -173,6 +209,7 @@ class CallManager(
cancelAllPeerTimeouts()
peersInvitedByUs.addAll(calleePubKeys)
calleePubKeys.forEach { schedulePeerTimeout(it, callId) }
armRingingWatchdog(callId)
}
/**
@@ -236,6 +273,7 @@ class CallManager(
cancelAllPeerTimeouts()
peersInvitedByUs.add(calleePubKey)
schedulePeerTimeout(calleePubKey, callId)
armRingingWatchdog(callId)
}
publishEvent(result.wrap)
Log.d("CallManager") { "initiateCall: offer published, per-peer timeout started" }
@@ -332,6 +370,7 @@ class CallManager(
callType = callType,
sdpOffer = event.sdpOffer(),
)
armRingingWatchdog(callId)
startTimeout(callId)
}
@@ -375,6 +414,7 @@ class CallManager(
pendingPeerPubKeys = pendingOnJoin,
)
cancelTimeout()
disarmRingingWatchdog()
// Local watchdog timers only — we are not the inviter for these
// peers, so [handlePeerTimeout] will silently drop them without
// publishing any hangup (see `peersInvitedByUs`).
@@ -471,6 +511,7 @@ class CallManager(
// The answered peer no longer needs its invite timer. Peers
// still in `pending` keep theirs (scheduled in beginOffering).
cancelPeerTimeout(answeringPeer)
disarmRingingWatchdog()
Log.d("CallManager") { "onCallAnswered: Offering -> Connecting, forwarding answer to CallController" }
onAnswerReceived?.invoke(event)
}
@@ -637,7 +678,7 @@ class CallManager(
else -> return
}
if (callId != currentCallId) return
onRenegotiationOfferReceived?.invoke(event)
_renegotiationEvents.tryEmit(event)
}
suspend fun sendRenegotiation(
@@ -956,6 +997,7 @@ class CallManager(
_state.value = CallState.Idle
cancelTimeout()
cancelAllPeerTimeouts()
disarmRingingWatchdog()
resetJob?.cancel()
resetJob = null
processedEventIds.clear()
@@ -974,6 +1016,7 @@ class CallManager(
_state.value = CallState.Ended(callId, peerPubKeys, reason)
cancelTimeout()
cancelAllPeerTimeouts()
disarmRingingWatchdog()
resetJob?.cancel()
resetJob =
scope.launch {
@@ -985,6 +1028,52 @@ class CallManager(
}
}
// ---- Ringing watchdog ----
/**
* Arms the watchdog for [callId]. If the state is still
* `IncomingCall(callId)` or `Offering(callId)` when the watchdog
* fires, forces `Ended(TIMEOUT)` so nothing can keep ringing
* indefinitely. Calling this replaces any previously armed watchdog.
*/
private fun armRingingWatchdog(callId: String) {
ringingWatchdogJob?.cancel()
ringingWatchdogJob =
watchdogScope.launch {
delay(RINGING_WATCHDOG_MS)
stateMutex.withLock {
val cur = _state.value
val hit =
(cur is CallState.IncomingCall && cur.callId == callId) ||
(cur is CallState.Offering && cur.callId == callId)
if (hit) {
Log.d("CallManager") { "Ringing watchdog fired for $callId — forcing Ended(TIMEOUT)" }
val peers =
when (cur) {
is CallState.IncomingCall -> cur.peerPubKeys()
is CallState.Offering -> cur.peerPubKeys
else -> emptySet()
}
transitionToEnded(callId, peers, EndReason.TIMEOUT)
}
}
}
}
private fun disarmRingingWatchdog() {
ringingWatchdogJob?.cancel()
ringingWatchdogJob = null
}
/**
* Cancels all long-lived coroutines owned by this manager. Called when
* the owning account scope is torn down (logout / process shutdown).
*/
fun dispose() {
disarmRingingWatchdog()
watchdogScope.cancel()
}
// ---- Per-peer invite timeout ----
/**