Stop the ring on this account's other devices when one answers

The answer goes to the caller only, so a second device on the same
account kept ringing for its full minute after the first picked up, and
then filed the call as missed. The device that answers or declines now
sends call-end to its own account's other devices, reason
answered-elsewhere or declined-elsewhere. A device still ringing for that
call stops and files nothing; one already on the call ignores it.

A send to this account's own devices carries no delivery key or key-log
prelude and is not refused for going unsealed. Its failures are logged
and never touch the call on this device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-11 12:50:59 -04:00
co-authored by Claude Opus 5
parent f6506292e7
commit 43baac80b7
5 changed files with 160 additions and 6 deletions
@@ -463,6 +463,14 @@ class CallManager(
"fnmsg-call",
"call signal in: ${incoming.action} from $fromUser state=$state",
)
// Another of this account's own devices, saying the call ringing here
// was answered or declined there. Nobody on a call sends as this
// account, so this is never the caller.
val own = ownUserId()
if (own != null && fromUser == own) {
onOwnDevice(incoming)
return
}
when (incoming.action) {
Content.CallSignal.OFFER -> {
// Supervision: a device with calls off does not ring, the
@@ -501,6 +509,7 @@ class CallManager(
incoming = true,
video = incoming.video,
)
endedElsewhere = false
state = State.RINGING
armRingGuard()
}
@@ -555,6 +564,43 @@ class CallManager(
/** An offer arrived too old to ring. The view model records the missed call. */
var onStaleOffer: ((Long, Long) -> Unit)? = null
/** This account's user id, read when a signal arrives. */
var ownUserId: () -> Long? = { null }
/**
* Sends one signal to this account's other devices. Best effort: a failure
* here must never reach the call on this device.
*/
var tellOwnDevices: ((Content.CallSignal) -> Unit)? = null
/** The ring that just ended was answered or declined on another device. */
var endedElsewhere = false
private set
/**
* An end from another of this account's devices, which answered or
* declined the call ringing here. The answer goes to the caller alone, so
* before this the ring here carried on for its full minute and was then
* filed as a missed call somebody had taken.
*/
private fun onOwnDevice(incoming: Content.CallSignal) {
// Only a ring stops. A device already on the call ignores it: both
// answering at once is the one way it arrives, and whichever answer
// reached the caller first has the call.
if (incoming.action != Content.CallSignal.END ||
state != State.RINGING ||
incoming.callId != current?.callId
) {
android.util.Log.i(
"fnmsg-call",
"own-device ${incoming.action} for ${incoming.callId} ignored: nothing ringing for it",
)
return
}
endedElsewhere = true
hangUp(notifyPeer = false, reason = incoming.reason)
}
/** Accept the call that is ringing. */
/**
@@ -593,6 +639,9 @@ class CallManager(
fun accept(turn: JSONObject?) {
val (fromUser, offer) = pendingOffer ?: return
pendingOffer = null
tellOwnDevices?.invoke(
Content.CallSignal(Content.CallSignal.END, offer.callId, reason = "answered-elsewhere"),
)
val sdp = offer.sdp ?: return
state = State.CONNECTING
armConnectGuard()
@@ -617,6 +666,9 @@ class CallManager(
fun decline() {
val (fromUser, offer) = pendingOffer ?: return
pendingOffer = null
tellOwnDevices?.invoke(
Content.CallSignal(Content.CallSignal.END, offer.callId, reason = "declined-elsewhere"),
)
signal(
fromUser,
Content.CallSignal(Content.CallSignal.END, offer.callId, reason = "declined"),
@@ -2369,7 +2369,10 @@ class MessengerViewModel(
.cancel(3)
}
calls?.current?.let { active ->
if (!callFiled) {
// Not a ring another of this account's devices
// answered or declined: that device files it, and
// a missed call here would be wrong.
if (!callFiled && calls?.endedElsewhere != true) {
callFiled = true
val connected = callWasConnected
viewModelScope.launch(Dispatchers.IO) {
@@ -2417,6 +2420,19 @@ class MessengerViewModel(
}
},
)
manager.ownUserId = { session?.userId }
manager.tellOwnDevices = { signal ->
viewModelScope.launch(Dispatchers.IO) {
runCatching { session?.sendCallSignalToOwnDevices(signal) }
.onFailure {
android.util.Log.w(
"fnmsg-call",
"could not tell this account's other devices to stop ringing: " +
"${it.javaClass.simpleName}: ${it.message}",
)
}
}
}
manager.onStaleOffer = { fromUser, _ ->
// Too old to ring, the caller gave up long ago, but not too old to
// matter: file it so the Calls tab shows who tried.
@@ -1069,6 +1069,13 @@ class Session(
return sent
}
/**
* Send one call signal to this account's OTHER devices, the ones still
* ringing for a call this one just answered or declined.
*/
fun sendCallSignalToOwnDevices(signal: io.flamenet.e2e.Content.CallSignal): Conversations.Sent =
conversations.sendCallSignal(userId, signal)
/** TURN credentials, or null when this relay has none configured. */
/**