Stop the ring on this account's other devices when one answers #152
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user