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

Merged
paulhitt merged 1 commits from fix/answered-elsewhere into main 2026-09-11 16:53:56 +00:00
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()"))
}
}
@@ -568,6 +568,10 @@ class Conversations(
toUserId: Long,
text: String,
): Sent {
// This account's own other devices. They need no delivery key or
// key-log gossip from us, and sealing hides nothing from a relay that
// already knows the account is talking to itself.
val toSelf = toUserId == myUserId
// The DIRECTORY, not the key route.
val served = relay.listDirectory(toUserId)
val directory = served.devices
@@ -652,13 +656,17 @@ class Conversations(
// ios#16, refuse rather than degrade, for the people who would rather a
// message did not go than go attributable.
if (!sealed && requireSealed()) throw RelayException.SealingRequired()
if (!sealed && !toSelf && requireSealed()) throw RelayException.SealingRequired()
val preludes =
listOfNotNull(
ourDeliveryKeyEnvelopeFor(toUserId),
ourKeyLogHeadEnvelopeFor(toUserId),
)
if (toSelf) {
emptyList()
} else {
listOfNotNull(
ourDeliveryKeyEnvelopeFor(toUserId),
ourKeyLogHeadEnvelopeFor(toUserId),
)
}
for (bundle in bundles) {
if (bundle.deviceId == identity.deviceId) continue // never talk to ourselves