A second device's ring no longer hangs up a call answered on the first #150

Merged
paulhitt merged 2 commits from fix/second-device-hangs-up-answered-call into main 2026-09-11 16:20:59 +00:00
2 changed files with 107 additions and 8 deletions
@@ -140,10 +140,14 @@ class CallManager(
"fnmsg-call",
"ring expired with no answer, releasing so the next call is not refused",
)
// "timeout", not a new word: the far end already renders
// that as "No answer", which is what this is. An unknown
// reason falls through to a flat "Call ended".
hangUp(notifyPeer = true, reason = "timeout")
// Released here and nothing sent. The caller gave up on its
// own timer long before this fires, so telling it was
// redundant, and on an account signed in on more than one
// device it was destructive: this phone keeps ringing when
// the call was answered on another one, because nothing
// tells it, and a timeout end sent from here reached the
// caller mid-call with that other device and hung it up.
hangUp(notifyPeer = false, reason = "timeout")
}
}
ringGuard = guard
@@ -293,7 +297,9 @@ class CallManager(
}
// CLOSED is what our OWN close() produces.
PeerConnection.IceConnectionState.CLOSED -> Unit
PeerConnection.IceConnectionState.CLOSED -> {
Unit
}
// The far end went away. Transient by definition,
// WebRTC recovers from it often, so this is a grace
@@ -301,11 +307,17 @@ class CallManager(
// is cancelled below. Without it a lost `call-end`
// left the screen up until the dial guard gave up
// three quarters of a minute later.
PeerConnection.IceConnectionState.DISCONNECTED -> armGoneGuard()
PeerConnection.IceConnectionState.DISCONNECTED -> {
armGoneGuard()
}
PeerConnection.IceConnectionState.FAILED -> hangUp(notifyPeer = true)
PeerConnection.IceConnectionState.FAILED -> {
hangUp(notifyPeer = true)
}
else -> Unit
else -> {
Unit
}
}
}
}
@@ -510,6 +522,31 @@ class CallManager(
}
Content.CallSignal.END -> {
// An end for some other call must not hang up this one. This
// branch used to hang up on any end at all, for any call id.
if (incoming.callId != current?.callId) {
android.util.Log.i(
"fnmsg-call",
"end for ${incoming.callId} ignored: not the current call",
)
return
}
// A call answered on one of the callee's devices is still
// ringing on the others, and they all share this call id. Once
// this call has connected, an end whose reason means the ring
// was never picked up came from one of those other devices and
// is not about the call in progress: somebody silencing their
// tablet, or a ring guard from a build that still announces its
// timeout. Honouring it hung up a live call. A plain hang-up
// carries no such reason and still ends it.
val neverPickedUp = incoming.reason.orEmpty() in setOf("declined", "busy", "timeout")
if (neverPickedUp && (state == State.CONNECTING || state == State.ACTIVE)) {
android.util.Log.i(
"fnmsg-call",
"'${incoming.reason}' end ignored: this call was already picked up",
)
return
}
hangUp(notifyPeer = false)
}
}
@@ -0,0 +1,62 @@
package io.flamenet.messenger.app
import java.io.File
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* One account on two devices: a ring on the second must not end a call that
* was answered on the first.
*
* `CallManager` needs WebRTC, which the JVM test runtime does not have, so this
* reads the source the way the iPhone's `RingTimesOutTests` does. That iPhone
* test reads this file as well, but only when the two checkouts sit side by
* side under the directory names it expects, which a CI build directory never
* has. This copy is the one that actually runs on every Android pipeline.
*/
class SecondDeviceCallTest {
private val source: String by lazy {
listOf(
"src/main/kotlin/io/flamenet/messenger/app/CallManager.kt",
"app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt",
).map(::File).first { it.exists() }.readText()
}
/** The function starting at [marker], up to the next one. */
private fun body(marker: String): String {
val start = source.indexOf(marker)
assertTrue(start >= 0, "no $marker in CallManager.kt")
val rest = source.substring(start + marker.length)
val end = rest.indexOf("private fun ").let { if (it < 0) rest.length else it }
return rest.substring(0, end)
}
@Test
fun `the ring guard releases the ring without telling the caller`() {
val guard = body("private fun armRingGuard()")
assertTrue(guard.contains("notifyPeer = false"), "the guard must not message the caller")
// Bounded to the guard: the connect guard below it still, correctly,
// hangs up with notifyPeer = true.
assertFalse(
guard.contains("notifyPeer = true"),
"a timeout sent from a second device hangs up a call answered on the first",
)
}
@Test
fun `an end hangs up only its own call, and not once that call has connected`() {
val start = source.indexOf("Content.CallSignal.END ->")
assertTrue(start >= 0, "no end branch in onSignal")
val branch = source.substring(start, minOf(source.length, start + 2000))
assertTrue(
branch.contains("incoming.callId != current?.callId"),
"an end for another call must not hang this one up",
)
assertTrue(branch.contains("setOf(\"declined\", \"busy\", \"timeout\")"))
assertTrue(
branch.contains("state == State.CONNECTING || state == State.ACTIVE"),
"only a call that has connected may ignore them; a ringing one must still stop",
)
}
}