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:
co-authored by
Claude Opus 5
parent
f6506292e7
commit
43baac80b7
@@ -463,6 +463,14 @@ class CallManager(
|
|||||||
"fnmsg-call",
|
"fnmsg-call",
|
||||||
"call signal in: ${incoming.action} from $fromUser state=$state",
|
"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) {
|
when (incoming.action) {
|
||||||
Content.CallSignal.OFFER -> {
|
Content.CallSignal.OFFER -> {
|
||||||
// Supervision: a device with calls off does not ring, the
|
// Supervision: a device with calls off does not ring, the
|
||||||
@@ -501,6 +509,7 @@ class CallManager(
|
|||||||
incoming = true,
|
incoming = true,
|
||||||
video = incoming.video,
|
video = incoming.video,
|
||||||
)
|
)
|
||||||
|
endedElsewhere = false
|
||||||
state = State.RINGING
|
state = State.RINGING
|
||||||
armRingGuard()
|
armRingGuard()
|
||||||
}
|
}
|
||||||
@@ -555,6 +564,43 @@ class CallManager(
|
|||||||
/** An offer arrived too old to ring. The view model records the missed call. */
|
/** An offer arrived too old to ring. The view model records the missed call. */
|
||||||
var onStaleOffer: ((Long, Long) -> Unit)? = null
|
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. */
|
/** Accept the call that is ringing. */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -593,6 +639,9 @@ class CallManager(
|
|||||||
fun accept(turn: JSONObject?) {
|
fun accept(turn: JSONObject?) {
|
||||||
val (fromUser, offer) = pendingOffer ?: return
|
val (fromUser, offer) = pendingOffer ?: return
|
||||||
pendingOffer = null
|
pendingOffer = null
|
||||||
|
tellOwnDevices?.invoke(
|
||||||
|
Content.CallSignal(Content.CallSignal.END, offer.callId, reason = "answered-elsewhere"),
|
||||||
|
)
|
||||||
val sdp = offer.sdp ?: return
|
val sdp = offer.sdp ?: return
|
||||||
state = State.CONNECTING
|
state = State.CONNECTING
|
||||||
armConnectGuard()
|
armConnectGuard()
|
||||||
@@ -617,6 +666,9 @@ class CallManager(
|
|||||||
fun decline() {
|
fun decline() {
|
||||||
val (fromUser, offer) = pendingOffer ?: return
|
val (fromUser, offer) = pendingOffer ?: return
|
||||||
pendingOffer = null
|
pendingOffer = null
|
||||||
|
tellOwnDevices?.invoke(
|
||||||
|
Content.CallSignal(Content.CallSignal.END, offer.callId, reason = "declined-elsewhere"),
|
||||||
|
)
|
||||||
signal(
|
signal(
|
||||||
fromUser,
|
fromUser,
|
||||||
Content.CallSignal(Content.CallSignal.END, offer.callId, reason = "declined"),
|
Content.CallSignal(Content.CallSignal.END, offer.callId, reason = "declined"),
|
||||||
|
|||||||
@@ -2369,7 +2369,10 @@ class MessengerViewModel(
|
|||||||
.cancel(3)
|
.cancel(3)
|
||||||
}
|
}
|
||||||
calls?.current?.let { active ->
|
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
|
callFiled = true
|
||||||
val connected = callWasConnected
|
val connected = callWasConnected
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
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, _ ->
|
manager.onStaleOffer = { fromUser, _ ->
|
||||||
// Too old to ring, the caller gave up long ago, but not too old to
|
// 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.
|
// matter: file it so the Calls tab shows who tried.
|
||||||
|
|||||||
@@ -1069,6 +1069,13 @@ class Session(
|
|||||||
return sent
|
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. */
|
/** 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",
|
"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,
|
toUserId: Long,
|
||||||
text: String,
|
text: String,
|
||||||
): Sent {
|
): 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.
|
// The DIRECTORY, not the key route.
|
||||||
val served = relay.listDirectory(toUserId)
|
val served = relay.listDirectory(toUserId)
|
||||||
val directory = served.devices
|
val directory = served.devices
|
||||||
@@ -652,13 +656,17 @@ class Conversations(
|
|||||||
|
|
||||||
// ios#16, refuse rather than degrade, for the people who would rather a
|
// ios#16, refuse rather than degrade, for the people who would rather a
|
||||||
// message did not go than go attributable.
|
// message did not go than go attributable.
|
||||||
if (!sealed && requireSealed()) throw RelayException.SealingRequired()
|
if (!sealed && !toSelf && requireSealed()) throw RelayException.SealingRequired()
|
||||||
|
|
||||||
val preludes =
|
val preludes =
|
||||||
|
if (toSelf) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
ourDeliveryKeyEnvelopeFor(toUserId),
|
ourDeliveryKeyEnvelopeFor(toUserId),
|
||||||
ourKeyLogHeadEnvelopeFor(toUserId),
|
ourKeyLogHeadEnvelopeFor(toUserId),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
for (bundle in bundles) {
|
for (bundle in bundles) {
|
||||||
if (bundle.deviceId == identity.deviceId) continue // never talk to ourselves
|
if (bundle.deviceId == identity.deviceId) continue // never talk to ourselves
|
||||||
|
|||||||
Reference in New Issue
Block a user