A second device's ring no longer hangs up a call answered on the first

With one account signed in on two devices, both ring. Answer on one and
the other keeps ringing, because nothing tells it; when its ring guard
fired it sent the caller an end with reason "timeout", and the caller
hung up the call in progress.

Two halves, both needed.

The ring guard now releases the ring locally and sends nothing. The
caller gives up on its own dial timer long before this fires, so the
message was redundant, and on a shared account it was destructive.

The end handler did not check the call id at all: any end, for any call,
hung up whatever call was current. It now requires the call id to match,
and once a call has connected it ignores an end whose reason means the
ring was never picked up, declined, busy or timeout. Those can only have
come from another device that was ringing: somebody silencing it, or a
build already in people's hands that still announces its timeout. A
plain hang-up carries no such reason and still ends the call.

The iPhone gets the same fix, and its test reads this file so the two
cannot drift apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-11 12:13:13 -04:00
co-authored by Claude Opus 5
parent 668bd48415
commit ae84b949a3
@@ -140,10 +140,14 @@ class CallManager(
"fnmsg-call",
"ring expired with no answer, releasing so the next call is not refused",
)
// "timeout", not a new word: the far end already renders
// that as "No answer", which is what this is. An unknown
// reason falls through to a flat "Call ended".
hangUp(notifyPeer = true, reason = "timeout")
// Released here and nothing sent. The caller gave up on its
// own timer long before this fires, so telling it was
// redundant, and on an account signed in on more than one
// device it was destructive: this phone keeps ringing when
// the call was answered on another one, because nothing
// tells it, and a timeout end sent from here reached the
// caller mid-call with that other device and hung it up.
hangUp(notifyPeer = false, reason = "timeout")
}
}
ringGuard = guard
@@ -293,7 +297,9 @@ class CallManager(
}
// 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
@@ -301,11 +307,17 @@ class CallManager(
// 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.DISCONNECTED -> {
armGoneGuard()
}
PeerConnection.IceConnectionState.FAILED -> hangUp(notifyPeer = true)
PeerConnection.IceConnectionState.FAILED -> {
hangUp(notifyPeer = true)
}
else -> Unit
else -> {
Unit
}
}
}
}
@@ -510,6 +522,31 @@ class CallManager(
}
Content.CallSignal.END -> {
// An end for some other call must not hang up this one. This
// branch used to hang up on any end at all, for any call id.
if (incoming.callId != current?.callId) {
android.util.Log.i(
"fnmsg-call",
"end for ${incoming.callId} ignored: not the current call",
)
return
}
// A call answered on one of the callee's devices is still
// ringing on the others, and they all share this call id. Once
// this call has connected, an end whose reason means the ring
// was never picked up came from one of those other devices and
// is not about the call in progress: somebody silencing their
// tablet, or a ring guard from a build that still announces its
// timeout. Honouring it hung up a live call. A plain hang-up
// carries no such reason and still ends it.
val neverPickedUp = incoming.reason.orEmpty() in setOf("declined", "busy", "timeout")
if (neverPickedUp && (state == State.CONNECTING || state == State.ACTIVE)) {
android.util.Log.i(
"fnmsg-call",
"'${incoming.reason}' end ignored: this call was already picked up",
)
return
}
hangUp(notifyPeer = false)
}
}