Do not stop reading the inbox while a call is up

The handover stops this app's polling loop when the screen goes away,
because the background service takes over. That is right for messages
and wrong for a call already in progress: the loop is the only thing
that routes `call-end` into the manager owning the call screen. The
service polls too, but it turns offers into notifications and knows
nothing about a call that is already up.

So a call ended by the far end while this app was off screen had nowhere
to land. The screen stayed, still offering to answer, until the dial
guard gave up forty-five seconds later. Seen on a device: a call placed
at 13:04:13, no `call-end` ever logged, ICE closed at 13:04:58.

Polling now refuses to pause while a call is dialling, ringing,
connecting or active.

And a backstop for when the signal is genuinely lost. ICE DISCONNECTED
was a no-op, so nothing noticed a far end that simply vanished. It now
arms an eight second grace, cancelled if the connection recovers,
because WebRTC blips are ordinary. A call whose audio has stopped is
over whatever the signalling says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-10 14:46:51 -04:00
co-authored by Claude Opus 5
parent 63f2758d46
commit 51e9f01323
2 changed files with 75 additions and 1 deletions
@@ -102,6 +102,35 @@ class CallManager(
*/ */
private var ringGuard: Runnable? = null private var ringGuard: Runnable? = null
/**
* Ends a call whose far end stopped answering the media.
*
* `call-end` is the polite path and usually arrives first. When it does not,
* because it was lost or the app was somewhere it could not be read, this is
* what notices. Short, because a call whose audio has stopped is over
* whatever the signalling says.
*/
private var goneGuard: Runnable? = null
private fun armGoneGuard() {
if (goneGuard != null) return
val guard =
Runnable {
goneGuard = null
if (state == State.ACTIVE || state == State.CONNECTING) {
android.util.Log.i("fnmsg-call", "far end gone, ending the call")
hangUp(notifyPeer = true, reason = "timeout")
}
}
goneGuard = guard
main.postDelayed(guard, GONE_TIMEOUT_MS)
}
private fun cancelGoneGuard() {
goneGuard?.let { main.removeCallbacks(it) }
goneGuard = null
}
private fun armRingGuard() { private fun armRingGuard() {
cancelRingGuard() cancelRingGuard()
val guard = val guard =
@@ -258,11 +287,22 @@ class CallManager(
when (newState) { when (newState) {
PeerConnection.IceConnectionState.CONNECTED, PeerConnection.IceConnectionState.CONNECTED,
PeerConnection.IceConnectionState.COMPLETED, PeerConnection.IceConnectionState.COMPLETED,
-> state = State.ACTIVE -> {
cancelGoneGuard()
state = State.ACTIVE
}
// CLOSED is what our OWN close() produces. // CLOSED is what our OWN close() produces.
PeerConnection.IceConnectionState.CLOSED -> Unit PeerConnection.IceConnectionState.CLOSED -> Unit
// The far end went away. Transient by definition,
// WebRTC recovers from it often, so this is a grace
// rather than a hang-up: if it comes back the timer
// is cancelled below. Without it a lost `call-end`
// left the screen up until the dial guard gave up
// three quarters of a minute later.
PeerConnection.IceConnectionState.DISCONNECTED -> armGoneGuard()
PeerConnection.IceConnectionState.FAILED -> hangUp(notifyPeer = true) PeerConnection.IceConnectionState.FAILED -> hangUp(notifyPeer = true)
else -> Unit else -> Unit
@@ -584,6 +624,8 @@ class CallManager(
dialGuard = null dialGuard = null
connectGuard?.let { main.removeCallbacks(it) } connectGuard?.let { main.removeCallbacks(it) }
connectGuard = null connectGuard = null
cancelGoneGuard()
cancelRingGuard()
val active = current val active = current
if (notifyPeer && active != null) { if (notifyPeer && active != null) {
// Best effort. // Best effort.
@@ -646,6 +688,12 @@ class CallManager(
* only when their hang-up never reached us. * only when their hang-up never reached us.
*/ */
const val RING_TIMEOUT_MS = 60_000L const val RING_TIMEOUT_MS = 60_000L
/**
* How long a connected call may stay disconnected before it is over.
* Long enough for a real blip, short enough not to be a stuck screen.
*/
const val GONE_TIMEOUT_MS = 8_000L
const val CONNECT_TIMEOUT_MS = 30_000L const val CONNECT_TIMEOUT_MS = 30_000L
/** /**
@@ -2334,6 +2334,16 @@ class MessengerViewModel(
onStateChanged = { state -> onStateChanged = { state ->
// Filed once, at the end. // Filed once, at the end.
if (state == CallManager.State.ENDED || state == CallManager.State.IDLE) { if (state == CallManager.State.ENDED || state == CallManager.State.IDLE) {
// The ring notification belongs to the service, and the
// manager knows nothing of it. A ring that ended here,
// by the caller hanging up or by the guard giving up,
// used to leave it standing: an Answer button for a
// call that no longer existed. Seen on a device.
runCatching {
androidx.core.app.NotificationManagerCompat
.from(getApplication())
.cancel(3)
}
calls?.current?.let { active -> calls?.current?.let { active ->
if (!callFiled) { if (!callFiled) {
callFiled = true callFiled = true
@@ -3457,6 +3467,22 @@ class MessengerViewModel(
} }
fun pausePolling() { fun pausePolling() {
// Never while a call is in flight. This loop is the only thing that
// routes `call-end` into the manager that owns the call screen; the
// service polls too, but it turns offers into notifications and knows
// nothing about a call already up. Stopping here mid-call left the far
// end's hang-up with nowhere to land, so the screen sat there offering
// to answer a call that had gone, until the dial guard gave up 45
// seconds later. Seen on a device.
when (calls?.state) {
CallManager.State.DIALLING,
CallManager.State.RINGING,
CallManager.State.CONNECTING,
CallManager.State.ACTIVE,
-> return
else -> Unit
}
polling?.cancel() polling?.cancel()
polling = null polling = null
} }