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