fix(call): stop ringing on sibling devices without disturbing the one that answered
When the user is logged in on two devices and receives a call, both ring. When one picks up, the other should stop ringing immediately — but crucially, the device that answered must not lose its WebRTC connection when the silenced device reacts. The "answered elsewhere" state machine (onCallAnswered at the self- pubkey branch) was already in place and covered by a unit test, but the signal it relies on was never actually published: acceptCall wrapped the CallAnswerEvent only for `groupMembers - signer.pubKey` so in a 1:1 call it only reached the caller. Sibling devices subscribed to gift wraps for their own pubkey never saw it and kept ringing until the 60 s local timeout. Fixes: - acceptCall / rejectCall publish an extra gift wrap of the *same* signed answer/reject event addressed to `signer.pubKey`, so sibling devices in IncomingCall observe the echo and transition to Ended(ANSWERED_ELSEWHERE / REJECTED) locally. Neither path publishes any further signaling, so the device that picked up is never disturbed. - onCallRejected: add a self-pubkey early-return mirroring onCallAnswered. Without it, a self-reject echoing back to a device already in Connecting/Connected hit the peer-rejection branches, firing onPeerLeft(signer.pubKey) — which would tell CallController to dispose its own PeerSession and tear down the live audio. - onSignalingEvent: record self-answer call-ids in completedCallIds alongside hangups and rejects, so an out-of-order relay replay delivering the self-answer before the original offer does not make a sibling device start ringing for a call it already knows was answered. Adds multi-device tests covering: the new self-addressed wraps in acceptCall/rejectCall, the end-to-end "two phones, one answers" scenario, the Connected / Connecting self-reject echo guards, and the out-of-order offer-after-self-answer replay case. https://claude.ai/code/session_01QVUnhr79hYqQuXFiEb8puk
This commit is contained in:
+51
-14
@@ -386,6 +386,20 @@ class CallManager(
|
|||||||
Log.d("CallManager") { "acceptCall: publishing answer to ${otherMembers.size} recipients" }
|
Log.d("CallManager") { "acceptCall: publishing answer to ${otherMembers.size} recipients" }
|
||||||
val result = factory.createGroupCallAnswer(sdpAnswer, otherMembers, current.callId, signer)
|
val result = factory.createGroupCallAnswer(sdpAnswer, otherMembers, current.callId, signer)
|
||||||
result.wraps.forEach { publishEvent(it) }
|
result.wraps.forEach { publishEvent(it) }
|
||||||
|
|
||||||
|
// Notify our own other devices so they stop ringing. We wrap the
|
||||||
|
// *same* signed answer event for our own pubkey and publish it as
|
||||||
|
// an extra gift wrap. A sibling device in IncomingCall will observe
|
||||||
|
// the self-answer and transition to Ended(ANSWERED_ELSEWHERE)
|
||||||
|
// locally (see onCallAnswered). This call is the only place the
|
||||||
|
// "answered elsewhere" signal is generated — without it, a second
|
||||||
|
// logged-in device keeps ringing until its 60-second local timeout.
|
||||||
|
//
|
||||||
|
// The echo that arrives back at *this* device is ignored in
|
||||||
|
// Connecting/Connected state by the self-answer guard at the top
|
||||||
|
// of onCallAnswered, so it cannot disturb the live call.
|
||||||
|
val selfWrap = EphemeralGiftWrapEvent.create(event = result.msg, recipientPubKey = signer.pubKey)
|
||||||
|
publishEvent(selfWrap)
|
||||||
Log.d("CallManager") { "acceptCall: answer published, now in Connecting state" }
|
Log.d("CallManager") { "acceptCall: answer published, now in Connecting state" }
|
||||||
|
|
||||||
// Trigger callee-to-callee mesh connections with peers we discovered
|
// Trigger callee-to-callee mesh connections with peers we discovered
|
||||||
@@ -410,6 +424,12 @@ class CallManager(
|
|||||||
val otherMembers = current.groupMembers - signer.pubKey
|
val otherMembers = current.groupMembers - signer.pubKey
|
||||||
val result = factory.createGroupReject(otherMembers, current.callId, signer = signer)
|
val result = factory.createGroupReject(otherMembers, current.callId, signer = signer)
|
||||||
result.wraps.forEach { publishEvent(it) }
|
result.wraps.forEach { publishEvent(it) }
|
||||||
|
|
||||||
|
// Notify our own other devices so they stop ringing. See the
|
||||||
|
// matching comment in acceptCall — sibling devices in IncomingCall
|
||||||
|
// pick up this echo and transition to Ended(REJECTED) locally.
|
||||||
|
val selfWrap = EphemeralGiftWrapEvent.create(event = result.msg, recipientPubKey = signer.pubKey)
|
||||||
|
publishEvent(selfWrap)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onCallAnswered(event: CallAnswerEvent) {
|
private fun onCallAnswered(event: CallAnswerEvent) {
|
||||||
@@ -534,6 +554,23 @@ class CallManager(
|
|||||||
val callId = event.callId()
|
val callId = event.callId()
|
||||||
val rejectingPeer = event.pubKey
|
val rejectingPeer = event.pubKey
|
||||||
|
|
||||||
|
// Self-reject: only meaningful as "rejected elsewhere" while we are
|
||||||
|
// still ringing (IncomingCall). In every other state it is our own
|
||||||
|
// echo from a relay after a sibling device rejected a call that we
|
||||||
|
// have already accepted and are now actively in — treating it as a
|
||||||
|
// peer rejection would drop ourselves from the call (onPeerLeft
|
||||||
|
// would dispose our own PeerConnection, killing the live audio on
|
||||||
|
// the device that picked up). Just drop the echo.
|
||||||
|
if (rejectingPeer == signer.pubKey) {
|
||||||
|
if (current is CallState.IncomingCall && callId == current.callId) {
|
||||||
|
Log.d("CallManager") { "onCallRejected: self-reject detected in IncomingCall — rejected elsewhere" }
|
||||||
|
transitionToEnded(current.callId, current.peerPubKeys(), EndReason.REJECTED)
|
||||||
|
} else {
|
||||||
|
Log.d("CallManager") { "onCallRejected: ignoring self-reject echo in ${current::class.simpleName}" }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
when (current) {
|
when (current) {
|
||||||
is CallState.Offering -> {
|
is CallState.Offering -> {
|
||||||
if (callId != current.callId) return
|
if (callId != current.callId) return
|
||||||
@@ -565,10 +602,7 @@ class CallManager(
|
|||||||
|
|
||||||
is CallState.IncomingCall -> {
|
is CallState.IncomingCall -> {
|
||||||
if (callId != current.callId) return
|
if (callId != current.callId) return
|
||||||
if (rejectingPeer == signer.pubKey) {
|
if (rejectingPeer == current.callerPubKey) {
|
||||||
// Own rejection from another device
|
|
||||||
transitionToEnded(current.callId, current.peerPubKeys(), EndReason.REJECTED)
|
|
||||||
} else if (rejectingPeer == current.callerPubKey) {
|
|
||||||
// Caller rejected/cancelled the call
|
// Caller rejected/cancelled the call
|
||||||
transitionToEnded(current.callId, current.groupMembers, EndReason.PEER_REJECTED)
|
transitionToEnded(current.callId, current.groupMembers, EndReason.PEER_REJECTED)
|
||||||
} else {
|
} else {
|
||||||
@@ -860,17 +894,20 @@ class CallManager(
|
|||||||
|
|
||||||
// Record call-ids from termination signals so that a later offer
|
// Record call-ids from termination signals so that a later offer
|
||||||
// for the same call is recognised as stale (common after app restart
|
// for the same call is recognised as stale (common after app restart
|
||||||
// when relay replays events out of order).
|
// when relay replays events out of order). For answers, only
|
||||||
if (event is CallHangupEvent || event is CallRejectEvent) {
|
// *self*-answers count as a termination signal: they mean another
|
||||||
val terminatedCallId =
|
// of our devices picked up, so ringing on this device should never
|
||||||
when (event) {
|
// start for the same call-id. Peer answers to our own offer are
|
||||||
is CallHangupEvent -> event.callId()
|
// not terminal — they are what moves us into Connecting.
|
||||||
is CallRejectEvent -> event.callId()
|
val terminatedCallId =
|
||||||
else -> null
|
when (event) {
|
||||||
}
|
is CallHangupEvent -> event.callId()
|
||||||
if (terminatedCallId != null) {
|
is CallRejectEvent -> event.callId()
|
||||||
cappedAdd(completedCallIds, terminatedCallId, MAX_COMPLETED_CALL_IDS)
|
is CallAnswerEvent -> if (event.pubKey == signer.pubKey) event.callId() else null
|
||||||
|
else -> null
|
||||||
}
|
}
|
||||||
|
if (terminatedCallId != null) {
|
||||||
|
cappedAdd(completedCallIds, terminatedCallId, MAX_COMPLETED_CALL_IDS)
|
||||||
}
|
}
|
||||||
|
|
||||||
when (event) {
|
when (event) {
|
||||||
|
|||||||
+191
@@ -1830,4 +1830,195 @@ class CallManagerTest {
|
|||||||
|
|
||||||
assertIs<CallState.IncomingCall>(manager.state.value)
|
assertIs<CallState.IncomingCall>(manager.state.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Multi-Device: Second Logged-In Device Must Stop Ringing
|
||||||
|
// ========================================================================
|
||||||
|
// When the user is logged in on two phones and Alice calls them, both
|
||||||
|
// devices ring. When one picks up (or rejects), the other must stop
|
||||||
|
// ringing without disturbing the device that answered.
|
||||||
|
//
|
||||||
|
// Mechanism: acceptCall / rejectCall publish an extra gift wrap of the
|
||||||
|
// same signed answer/reject event addressed to the user's own pubkey,
|
||||||
|
// so a sibling device (subscribed to gift wraps for its own pubkey)
|
||||||
|
// observes the echo, detects a self-answer/self-reject in IncomingCall
|
||||||
|
// state and transitions to Ended(ANSWERED_ELSEWHERE / REJECTED).
|
||||||
|
|
||||||
|
private fun EphemeralGiftWrapEvent.recipientPubKey(): HexKey? =
|
||||||
|
tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun acceptCallPublishesAnswerWrappedForSelfSoSiblingDeviceCanStopRinging() =
|
||||||
|
runTest {
|
||||||
|
val (manager, published) = createManager(localPubKey = bob, followedKeys = setOf(alice))
|
||||||
|
|
||||||
|
manager.onSignalingEvent(makeOffer(from = alice, to = bob))
|
||||||
|
assertIs<CallState.IncomingCall>(manager.state.value)
|
||||||
|
published.clear()
|
||||||
|
|
||||||
|
manager.acceptCall(sdpAnswer)
|
||||||
|
|
||||||
|
val recipients = published.mapNotNull { it.recipientPubKey() }.toSet()
|
||||||
|
assertTrue(
|
||||||
|
alice in recipients,
|
||||||
|
"Answer must be wrapped for the caller (alice), recipients=$recipients",
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
bob in recipients,
|
||||||
|
"Answer must also be wrapped for the callee's own pubkey (bob) so " +
|
||||||
|
"sibling devices stop ringing, recipients=$recipients",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectCallPublishesRejectWrappedForSelfSoSiblingDeviceCanStopRinging() =
|
||||||
|
runTest {
|
||||||
|
val (manager, published) = createManager(localPubKey = bob, followedKeys = setOf(alice))
|
||||||
|
|
||||||
|
manager.onSignalingEvent(makeOffer(from = alice, to = bob))
|
||||||
|
assertIs<CallState.IncomingCall>(manager.state.value)
|
||||||
|
published.clear()
|
||||||
|
|
||||||
|
manager.rejectCall()
|
||||||
|
|
||||||
|
val recipients = published.mapNotNull { it.recipientPubKey() }.toSet()
|
||||||
|
assertTrue(
|
||||||
|
alice in recipients,
|
||||||
|
"Reject must be wrapped for the caller (alice), recipients=$recipients",
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
bob in recipients,
|
||||||
|
"Reject must also be wrapped for the callee's own pubkey (bob) so " +
|
||||||
|
"sibling devices stop ringing, recipients=$recipients",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulates the full multi-device scenario:
|
||||||
|
* 1. Alice calls Bob. Bob is logged in on two phones (two separate
|
||||||
|
* CallManagers backed by the same bobSigner).
|
||||||
|
* 2. Both phones receive the offer and enter IncomingCall.
|
||||||
|
* 3. Phone 1 accepts — this publishes an answer gift-wrapped for
|
||||||
|
* alice AND for bob.
|
||||||
|
* 4. Phone 2 (subscribed to its own pubkey) receives the self-
|
||||||
|
* addressed echo and must transition to Ended(ANSWERED_ELSEWHERE).
|
||||||
|
* 5. Phone 1 must remain in Connecting — the echo it also receives
|
||||||
|
* must not disturb its in-progress session.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun answeringOnDeviceOneStopsDeviceTwoRingingWithoutDisturbingDeviceOne() =
|
||||||
|
runTest {
|
||||||
|
val (phone1, published1) = createManager(localPubKey = bob, followedKeys = setOf(alice))
|
||||||
|
val (phone2, _) = createManager(localPubKey = bob, followedKeys = setOf(alice))
|
||||||
|
|
||||||
|
val offer = makeOffer(from = alice, to = bob)
|
||||||
|
phone1.onSignalingEvent(offer)
|
||||||
|
phone2.onSignalingEvent(offer)
|
||||||
|
assertIs<CallState.IncomingCall>(phone1.state.value)
|
||||||
|
assertIs<CallState.IncomingCall>(phone2.state.value)
|
||||||
|
published1.clear()
|
||||||
|
|
||||||
|
// Phone 1 picks up. It will publish one wrap for alice and one
|
||||||
|
// wrap for bob (the self-echo).
|
||||||
|
phone1.acceptCall(sdpAnswer)
|
||||||
|
assertIs<CallState.Connecting>(phone1.state.value)
|
||||||
|
|
||||||
|
// Deliver the self-echo to phone 2. The inner event is what
|
||||||
|
// another CallManager would see after unwrapping the gift wrap,
|
||||||
|
// so construct a parallel CallAnswerEvent directly (the wrap
|
||||||
|
// payload is opaque in the test harness because published1
|
||||||
|
// stores EphemeralGiftWrapEvents, not the unwrapped inner).
|
||||||
|
val selfAnswer = makeAnswer(from = bob, to = alice)
|
||||||
|
phone2.onSignalingEvent(selfAnswer)
|
||||||
|
|
||||||
|
// Phone 2 stops ringing with ANSWERED_ELSEWHERE.
|
||||||
|
val phone2State = phone2.state.value
|
||||||
|
assertIs<CallState.Ended>(phone2State)
|
||||||
|
assertEquals(EndReason.ANSWERED_ELSEWHERE, phone2State.reason)
|
||||||
|
|
||||||
|
// Phone 1 ALSO receives the self-echo (it is addressed to bob).
|
||||||
|
// It must stay in Connecting — the live call cannot be torn down
|
||||||
|
// by its own answer bouncing off the relay.
|
||||||
|
phone1.onSignalingEvent(selfAnswer)
|
||||||
|
assertIs<CallState.Connecting>(phone1.state.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression for the subtle bug where a self-reject echo would trigger
|
||||||
|
* onPeerLeft(signer.pubKey) on a device that had already accepted,
|
||||||
|
* disposing its own PeerSession and killing the live call.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun selfRejectEchoDoesNotDisturbDeviceAlreadyInCall() =
|
||||||
|
runTest {
|
||||||
|
val (manager, _) = createManager(localPubKey = bob, followedKeys = setOf(alice))
|
||||||
|
|
||||||
|
var peerLeftCalled = false
|
||||||
|
manager.onPeerLeft = { peerLeftCalled = true }
|
||||||
|
|
||||||
|
manager.onSignalingEvent(makeOffer(from = alice, to = bob))
|
||||||
|
manager.acceptCall(sdpAnswer)
|
||||||
|
manager.onPeerConnected()
|
||||||
|
assertIs<CallState.Connected>(manager.state.value)
|
||||||
|
|
||||||
|
// A sibling device rejects the call after we already answered.
|
||||||
|
// Its reject event is wrapped for us too (multi-device signal).
|
||||||
|
val siblingReject = makeReject(from = bob, to = alice)
|
||||||
|
manager.onSignalingEvent(siblingReject)
|
||||||
|
|
||||||
|
// Our live call must survive — the echo must not fire
|
||||||
|
// onPeerLeft (which would dispose our own PeerSession) and must
|
||||||
|
// not change state.
|
||||||
|
assertIs<CallState.Connected>(manager.state.value)
|
||||||
|
assertTrue(
|
||||||
|
!peerLeftCalled,
|
||||||
|
"onPeerLeft must not fire for a self-reject echo — the UI would " +
|
||||||
|
"tear down our own PeerSession, killing the live call audio.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same guard for the Connecting state: a self-reject arriving during
|
||||||
|
* the connection handshake must not drop us from our own pending set
|
||||||
|
* and must not invoke onPeerLeft(self).
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun selfRejectEchoDuringConnectingDoesNotFireOnPeerLeft() =
|
||||||
|
runTest {
|
||||||
|
val (manager, _) = createManager(localPubKey = bob, followedKeys = setOf(alice))
|
||||||
|
|
||||||
|
var peerLeftCalled = false
|
||||||
|
manager.onPeerLeft = { peerLeftCalled = true }
|
||||||
|
|
||||||
|
manager.onSignalingEvent(makeOffer(from = alice, to = bob))
|
||||||
|
manager.acceptCall(sdpAnswer)
|
||||||
|
assertIs<CallState.Connecting>(manager.state.value)
|
||||||
|
|
||||||
|
val siblingReject = makeReject(from = bob, to = alice)
|
||||||
|
manager.onSignalingEvent(siblingReject)
|
||||||
|
|
||||||
|
assertIs<CallState.Connecting>(manager.state.value)
|
||||||
|
assertTrue(!peerLeftCalled, "onPeerLeft must not fire for a self-reject echo during Connecting")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the relay replays events out of order — self-answer arriving
|
||||||
|
* before the original offer — the second device must NOT start ringing
|
||||||
|
* for a call-id it already knows was answered elsewhere.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun offerReplayedAfterSelfAnswerDoesNotStartRinging() =
|
||||||
|
runTest {
|
||||||
|
val (manager, _) = createManager(localPubKey = bob, followedKeys = setOf(alice))
|
||||||
|
|
||||||
|
// Self-answer arrives first (while we're in Idle — nothing to
|
||||||
|
// do with it except remember the call-id so a later offer for
|
||||||
|
// it is treated as stale).
|
||||||
|
manager.onSignalingEvent(makeAnswer(from = bob, to = alice))
|
||||||
|
assertIs<CallState.Idle>(manager.state.value)
|
||||||
|
|
||||||
|
// Now the offer finally arrives. We must NOT start ringing.
|
||||||
|
manager.onSignalingEvent(makeOffer(from = alice, to = bob))
|
||||||
|
assertIs<CallState.Idle>(manager.state.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user