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. */
/**
@@ -59,4 +59,75 @@ class SecondDeviceCallTest {
"only a call that has connected may ignore them; a ringing one must still stop",
)
}
/** From [from] up to [to], for functions that are not followed by a private one. */
private fun between(
from: String,
to: String,
): String {
val start = source.indexOf(from)
assertTrue(start >= 0, "no $from in CallManager.kt")
val end = source.indexOf(to, start + from.length)
assertTrue(end > start, "no $to after $from")
return source.substring(start, end)
}
private fun read(vararg paths: String): String = paths.map(::File).first { it.exists() }.readText()
// The answer goes to the caller and nowhere else, so another device on the
// same account had no way to know the call was picked up: it rang for its
// full minute and then filed the call as missed.
@Test
fun `answering here stops the ring on this account's other devices`() {
assertTrue(between("fun accept(", "fun decline(").contains("\"answered-elsewhere\""))
}
@Test
fun `declining here stops it too`() {
assertTrue(between("fun decline(", "fun hangUp(").contains("\"declined-elsewhere\""))
}
@Test
fun `a signal from this account is handled before the call logic`() {
val routed = source.indexOf("onOwnDevice(incoming)")
val dispatch = source.indexOf("when (incoming.action)")
assertTrue(routed in 0 until dispatch, "own-device signals must be routed first")
}
@Test
fun `only a ringing call stops for another device, and nothing is sent back`() {
val own = body("private fun onOwnDevice(")
assertTrue(own.contains("state != State.RINGING"))
assertTrue(own.contains("incoming.callId != current?.callId"))
assertTrue(own.contains("endedElsewhere = true"))
assertTrue(own.contains("notifyPeer = false"))
assertFalse(own.contains("notifyPeer = true"))
}
@Test
fun `a ring ended elsewhere is not filed as a missed call`() {
val vm =
read(
"src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt",
"app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt",
)
assertTrue(vm.contains("!callFiled && calls?.endedElsewhere != true"))
assertTrue(vm.contains("manager.tellOwnDevices ="))
}
@Test
fun `a send to this account's own devices carries no preludes`() {
val conv =
read(
"../client/src/main/kotlin/io/flamenet/client/Conversations.kt",
"client/src/main/kotlin/io/flamenet/client/Conversations.kt",
)
val start = conv.indexOf("private fun sendPlaintext(")
assertTrue(start >= 0)
val send = conv.substring(start, conv.indexOf("\n fun ", start))
assertTrue(send.contains("val toSelf = toUserId == myUserId"))
assertTrue(send.contains("!sealed && !toSelf && requireSealed()"))
assertTrue(send.contains("if (toSelf) {\n emptyList()"))
}
}