From 43baac80b7b55efee74f06e380f4e6db053d6b7f Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Fri, 11 Sep 2026 12:50:59 -0400 Subject: [PATCH] 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 --- .../io/flamenet/messenger/app/CallManager.kt | 52 ++++++++++++++ .../messenger/app/MessengerViewModel.kt | 18 ++++- .../io/flamenet/messenger/app/Session.kt | 7 ++ .../messenger/app/SecondDeviceCallTest.kt | 71 +++++++++++++++++++ .../io/flamenet/client/Conversations.kt | 18 +++-- 5 files changed, 160 insertions(+), 6 deletions(-) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt index eec168e..6aa332e 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt @@ -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"), diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 47cbd49..6c5ec3c 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -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. diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt index 08945e9..94ecf9d 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/Session.kt @@ -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. */ /** diff --git a/app/src/test/kotlin/io/flamenet/messenger/app/SecondDeviceCallTest.kt b/app/src/test/kotlin/io/flamenet/messenger/app/SecondDeviceCallTest.kt index f0c8692..c35673d 100644 --- a/app/src/test/kotlin/io/flamenet/messenger/app/SecondDeviceCallTest.kt +++ b/app/src/test/kotlin/io/flamenet/messenger/app/SecondDeviceCallTest.kt @@ -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()")) + } } diff --git a/client/src/main/kotlin/io/flamenet/client/Conversations.kt b/client/src/main/kotlin/io/flamenet/client/Conversations.kt index fd1527e..9ac4c3a 100644 --- a/client/src/main/kotlin/io/flamenet/client/Conversations.kt +++ b/client/src/main/kotlin/io/flamenet/client/Conversations.kt @@ -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 -- 2.54.0