diff --git a/docs/CALL_TUNNEL.md b/docs/CALL_TUNNEL.md index b51bc5dd..898a2312 100644 --- a/docs/CALL_TUNNEL.md +++ b/docs/CALL_TUNNEL.md @@ -31,6 +31,33 @@ to JSON-RPC clients, which use them to connect audio via platform APIs. --- +## Multi-device compatibility + +A tunnel supporting device-aware hangup/busy handling includes +`"signalingVersion": 2` in its `ready` event. The separate +`{"type":"signalingCapabilities","version":2}` event is also accepted for +compatibility with tunnels advertising capabilities separately. + +For this capability, signal-cli forwards incoming `receivedHangup` messages with +`senderDeviceId`, numeric `hangupType` (0 normal, 1 accepted elsewhere, 2 declined +elsewhere, 3 busy elsewhere, 4 permission needed), and `deviceId` identifying the +accepted/declined device. `receivedBusy` carries `senderDeviceId`. RingRTC decides +which device connection should end. + +Outgoing signaling events may include `receiverDeviceId`: an integer targets +one device, explicit null broadcasts, and an absent field retains the legacy +call target. A `sendHangup` also carries `deviceId` for the accepted/declined +device; this is distinct from its delivery target. + +An older tunnel keeps the existing direct hangup/busy cleanup and notification +filtering. An older CLI ignores the added capability and target fields. Either +project can upgrade independently; complete multi-device behavior requires both. +Linked accounts also require the local device-ID correction included in this branch. +Notifications received before capability negotiation are retained until the version +is known. An unversioned `ready` alone does not settle negotiation: a subsequent +capability event enables version 2; a first legacy signaling/state event selects +legacy behavior. + ## Spawning the Tunnel For each call, signal-cli: diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java b/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java index de97899e..eb767692 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/CallManager.java @@ -276,8 +276,12 @@ public class CallManager implements AutoCloseable { answerMsg.put("receiverIdentityKey", java.util.Base64.getEncoder().encodeToString(localIdentityKey)); sendControlMessage(state, writeJson(answerMsg)); - state.deviceId = deviceId; - state.state = CallInfo.State.CONNECTING; + if (!Boolean.TRUE.equals(state.multiDeviceSignaling)) { + state.deviceId = deviceId; + } + if (state.state != CallInfo.State.CONNECTED && state.state != CallInfo.State.RECONNECTING) { + state.state = CallInfo.State.CONNECTING; + } fireCallEvent(state, null); logger.debug("Received answer for call {}", callIdUnsigned(callId)); @@ -300,18 +304,62 @@ public class CallManager implements AutoCloseable { logger.debug("Forwarded ICE candidate to tunnel for call {}", callIdUnsigned(callId)); } - public void handleIncomingHangup(final long callId) { - if (callEventListeners.isEmpty() && !activeCalls.containsKey(callId)) { + public void handleIncomingHangup(final RecipientId sender, final long callId, final int senderDeviceId, + final HangupMessage.Type type, final int deviceId) { + var state = activeCalls.get(callId); + if (state == null || !state.recipientId.equals(sender)) { return; } - endCall(callId, "remote_hangup"); + synchronized (state) { + if (activeCalls.get(callId) != state) { + return; + } + if (state.multiDeviceSignaling == null && type != null && type != HangupMessage.Type.NORMAL) { + state.pendingRemoteNotifications.add(() -> handleIncomingHangup(sender, callId, senderDeviceId, type, deviceId)); + return; + } + } + if (!Boolean.TRUE.equals(state.multiDeviceSignaling)) { + if (type == null || type == HangupMessage.Type.NORMAL) { + endCall(callId, "remote_hangup"); + } + return; + } + var message = mapper.createObjectNode(); + message.put("type", "receivedHangup"); + message.put("senderDeviceId", senderDeviceId); + message.put("hangupType", type == null ? 0 : switch (type) { + case NORMAL -> 0; + case ACCEPTED -> 1; + case DECLINED -> 2; + case BUSY -> 3; + case NEED_PERMISSION -> 4; + }); + message.put("deviceId", deviceId); + sendRemoteControlMessage(state, message); } - public void handleIncomingBusy(final long callId) { - if (callEventListeners.isEmpty() && !activeCalls.containsKey(callId)) { + public void handleIncomingBusy(final RecipientId sender, final long callId, final int senderDeviceId) { + var state = activeCalls.get(callId); + if (state == null || !state.recipientId.equals(sender)) { return; } - endCall(callId, "remote_busy"); + if (!Boolean.TRUE.equals(state.multiDeviceSignaling)) { + endCall(callId, "remote_busy"); + return; + } + var message = mapper.createObjectNode(); + message.put("type", "receivedBusy"); + message.put("senderDeviceId", senderDeviceId); + sendRemoteControlMessage(state, message); + } + + private void sendRemoteControlMessage(CallState state, ObjectNode message) { + synchronized (state) { + if (activeCalls.get(state.callId) == state) { + sendControlMessage(state, writeJson(message)); + } + } } // --- Internal helpers --- @@ -469,6 +517,17 @@ public class CallManager implements AutoCloseable { return writeJson(config); } + private void setSignalingVersion(CallState state, int version) { + synchronized (state) { + state.multiDeviceSignaling = version >= 2; + var pending = List.copyOf(state.pendingRemoteNotifications); + state.pendingRemoteNotifications.clear(); + for (var notification : pending) { + notification.run(); + } + } + } + private void readControlEvents(CallState state, java.io.InputStream inputStream) { try (var reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { String line; @@ -481,8 +540,17 @@ public class CallManager implements AutoCloseable { var json = mapper.readTree(line); var type = json.has("type") ? json.get("type").asText() : ""; + if (state.multiDeviceSignaling == null && switch (type) { + case "sendOffer", "sendAnswer", "sendIce", "sendHangup", "sendBusy", "stateChange" -> true; + default -> false; + }) { + setSignalingVersion(state, 1); + } switch (type) { case "ready" -> { + if (json.has("signalingVersion")) { + setSignalingVersion(state, json.path("signalingVersion").asInt(1)); + } if (json.has("inputDeviceName")) { state.inputDeviceName = json.get("inputDeviceName").asText(); } @@ -494,15 +562,18 @@ public class CallManager implements AutoCloseable { state.inputDeviceName, state.outputDeviceName); } + case "signalingCapabilities" -> { + setSignalingVersion(state, json.path("version").asInt(1)); + } case "sendOffer" -> { var opaqueB64 = json.get("opaque").asText(); var opaque = java.util.Base64.getDecoder().decode(opaqueB64); - logSendMessageResult(sendOfferViaSignal(state, opaque)); + logSendMessageResult(sendOfferViaSignal(state, opaque, receiverDeviceId(state, json))); } case "sendAnswer" -> { var opaqueB64 = json.get("opaque").asText(); var opaque = java.util.Base64.getDecoder().decode(opaqueB64); - logSendMessageResult(sendAnswerViaSignal(state, opaque)); + logSendMessageResult(sendAnswerViaSignal(state, opaque, receiverDeviceId(state, json))); } case "sendIce" -> { var candidatesArr = json.get("candidates"); @@ -510,7 +581,7 @@ public class CallManager implements AutoCloseable { for (var c : candidatesArr) { opaqueList.add(java.util.Base64.getDecoder().decode(c.get("opaque").asText())); } - logSendMessageResult(sendIceViaSignal(state, opaqueList)); + logSendMessageResult(sendIceViaSignal(state, opaqueList, receiverDeviceId(state, json))); } case "sendHangup" -> { // RingRTC wants us to send a hangup message via Signal protocol. @@ -518,16 +589,14 @@ public class CallManager implements AutoCloseable { var hangupType = json.has("hangupType") ? json.get("hangupType").asText("normal") : "normal"; - // Skip multi-device hangup types — signal-cli is single-device, - // and sending these to the remote peer causes it to terminate the call. - if (hangupType.contains("onanotherdevice")) { - logger.debug("Ignoring multi-device hangup type: {}", hangupType); - } else { - logSendMessageResult(sendHangupViaSignal(state, hangupType)); + if (!Boolean.TRUE.equals(state.multiDeviceSignaling) && hangupType.contains("onanotherdevice")) { + break; } + logSendMessageResult(sendHangupViaSignal(state, hangupType, + json.path("deviceId").asInt(0), receiverDeviceId(state, json))); } case "sendBusy" -> { - logSendMessageResult(sendBusyViaSignal(state)); + logSendMessageResult(sendBusyViaSignal(state, receiverDeviceId(state, json))); } case "stateChange" -> { var ringrtcState = json.get("state").asText(); @@ -616,45 +685,55 @@ public class CallManager implements AutoCloseable { } } - private SendMessageResult sendOfferViaSignal(CallState state, byte[] opaque) { + private static Integer receiverDeviceId(CallState state, com.fasterxml.jackson.databind.JsonNode event) { + var value = event.get("receiverDeviceId"); + return value == null ? state.deviceId : value.isNull() ? null : value.intValue(); + } + + private SendMessageResult sendOfferViaSignal(CallState state, byte[] opaque, Integer receiverDeviceId) { var offerMessage = new OfferMessage(state.callId, OfferMessage.Type.AUDIO_CALL, opaque); - var callMessage = SignalServiceCallMessage.forOffer(offerMessage, state.deviceId); + var callMessage = SignalServiceCallMessage.forOffer(offerMessage, receiverDeviceId); final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId); logger.debug("Sent offer via Signal for call {}", callIdUnsigned(state.callId)); return result; } - private SendMessageResult sendAnswerViaSignal(CallState state, byte[] opaque) { + private SendMessageResult sendAnswerViaSignal(CallState state, byte[] opaque, Integer receiverDeviceId) { var answerMessage = new AnswerMessage(state.callId, opaque); - var callMessage = SignalServiceCallMessage.forAnswer(answerMessage, state.deviceId); + var callMessage = SignalServiceCallMessage.forAnswer(answerMessage, receiverDeviceId); final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId); logger.debug("Sent answer via Signal for call {}", callIdUnsigned(state.callId)); return result; } - private SendMessageResult sendIceViaSignal(CallState state, List opaqueList) { + private SendMessageResult sendIceViaSignal(CallState state, List opaqueList, Integer receiverDeviceId) { var iceUpdates = opaqueList.stream().map(opaque -> new IceUpdateMessage(state.callId, opaque)).toList(); - var callMessage = SignalServiceCallMessage.forIceUpdates(iceUpdates, state.deviceId); + var callMessage = SignalServiceCallMessage.forIceUpdates(iceUpdates, receiverDeviceId); final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId); logger.debug("Sent {} ICE candidates via Signal for call {}", opaqueList.size(), callIdUnsigned(state.callId)); return result; } - private SendMessageResult sendBusyViaSignal(CallState state) { + private SendMessageResult sendBusyViaSignal(CallState state, Integer receiverDeviceId) { var busyMessage = new BusyMessage(state.callId); - var callMessage = SignalServiceCallMessage.forBusy(busyMessage, state.deviceId); + var callMessage = SignalServiceCallMessage.forBusy(busyMessage, receiverDeviceId); return context.getSendHelper().sendCallMessage(callMessage, state.recipientId); } - private SendMessageResult sendHangupViaSignal(CallState state, String hangupType) { + private static SignalServiceCallMessage hangupMessage(long callId, String hangupType, int deviceId, Integer receiverDeviceId) { var type = switch (hangupType) { case "accepted", "acceptedonanotherdevice" -> HangupMessage.Type.ACCEPTED; case "declined", "declinedonanotherdevice" -> HangupMessage.Type.DECLINED; case "busy", "busyonanotherdevice" -> HangupMessage.Type.BUSY; + case "needpermission" -> HangupMessage.Type.NEED_PERMISSION; default -> HangupMessage.Type.NORMAL; }; - var hangupMessage = new HangupMessage(state.callId, type, 0); - var callMessage = SignalServiceCallMessage.forHangup(hangupMessage, state.deviceId); + var hangupMessage = new HangupMessage(callId, type, deviceId); + return SignalServiceCallMessage.forHangup(hangupMessage, receiverDeviceId); + } + + private SendMessageResult sendHangupViaSignal(CallState state, String hangupType, int deviceId, Integer receiverDeviceId) { + var callMessage = hangupMessage(state.callId, hangupType, deviceId, receiverDeviceId); final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId); logger.debug("Sent hangup ({}) via Signal for call {}", hangupType, callIdUnsigned(state.callId)); return result; @@ -700,13 +779,23 @@ public class CallManager implements AutoCloseable { } } - private void endCall(final long callId, final String reason) { - var state = activeCalls.remove(callId); + CallState removeCall(final long callId) { + var state = activeCalls.get(callId); + if (state == null) return null; + synchronized (state) { + if (!activeCalls.remove(callId, state)) return null; + state.pendingRemoteNotifications.clear(); + state.state = CallInfo.State.ENDED; + return state; + } + } + + void endCall(final long callId, final String reason) { + var state = removeCall(callId); + if (state == null) return; dependencies.getAuthenticatedSignalWebSocket().removeKeepAliveToken("call" + callId); dependencies.getUnauthenticatedSignalWebSocket().removeKeepAliveToken("call" + callId); - if (state == null) return; - state.state = CallInfo.State.ENDED; fireCallEvent(state, reason); logger.debug("Call {} ended: {}", callIdUnsigned(callId), reason); @@ -775,6 +864,9 @@ public class CallManager implements AutoCloseable { volatile CallInfo.State state; final RecipientId recipientId; volatile Integer deviceId; + // Unknown until capabilities or the first legacy call event arrive. + volatile Boolean multiDeviceSignaling; + final List pendingRemoteNotifications = new ArrayList<>(); final boolean isOutgoing; volatile String inputDeviceName; volatile String outputDeviceName; diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java b/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java index 7af7b28c..b03b0675 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/IncomingMessageHandler.java @@ -463,17 +463,9 @@ public final class IncomingMessageHandler { } }); - callMessage.getHangupMessage().ifPresent(hangup -> { - // Only NORMAL hangups actually end the call. ACCEPTED/DECLINED/BUSY - // are multi-device notifications irrelevant for single-device signal-cli. - var hangupType = hangup.getType(); - if (hangupType == org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.NORMAL - || hangupType == null) { - callManager.handleIncomingHangup(hangup.getId()); - } - }); - - callMessage.getBusyMessage().ifPresent(busy -> callManager.handleIncomingBusy(busy.getId())); + callMessage.getHangupMessage().ifPresent(hangup -> + callManager.handleIncomingHangup(sender, hangup.getId(), deviceId, hangup.getType(), hangup.getDeviceId())); + callMessage.getBusyMessage().ifPresent(busy -> callManager.handleIncomingBusy(sender, busy.getId(), deviceId)); } private boolean handlePniSignatureMessage( diff --git a/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerSignalingTest.java b/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerSignalingTest.java new file mode 100644 index 00000000..aeb2f20f --- /dev/null +++ b/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerSignalingTest.java @@ -0,0 +1,274 @@ +package org.asamk.signal.manager.helper; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.asamk.signal.manager.Settings; +import org.asamk.signal.manager.api.CallInfo; +import org.asamk.signal.manager.api.ServiceEnvironment; +import org.asamk.signal.manager.storage.SignalAccount; +import org.asamk.signal.manager.storage.recipients.RecipientId; +import org.asamk.signal.manager.storage.recipients.TestRecipientId; +import org.asamk.signal.manager.util.KeyUtils; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.signal.core.models.ServiceId.ACI; +import org.whispersystems.signalservice.api.messages.calls.HangupMessage; +import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +class CallManagerSignalingTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + @TempDir + Path directory; + private SignalAccount account; + private RecordingCallManager manager; + private CallManager.CallState state; + private Map calls; + private final StringWriter output = new StringWriter(); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() throws Exception { + account = SignalAccount.create(directory.toFile(), "account", "+12025550100", + ACI.parseOrThrow("11111111-1111-4111-8111-111111111111"), ServiceEnvironment.STAGING, + KeyUtils.generateIdentityKeyPair(), KeyUtils.generateIdentityKeyPair(), KeyUtils.createProfileKey(), + Settings.DEFAULT); + manager = new RecordingCallManager(new Context(account, null, null, null, null, null)); + var recipient = account.getRecipientStore().resolveRecipient( + ACI.parseOrThrow("22222222-2222-4222-8222-222222222222")); + state = new CallManager.CallState(-1L, CallInfo.State.CONNECTED, recipient, null, true); + state.controlWriter = new PrintWriter(output, true); + var field = CallManager.class.getDeclaredField("activeCalls"); + field.setAccessible(true); + calls = (Map) field.get(manager); + calls.put(state.callId, state); + } + + @AfterEach + void close() throws Exception { + if (calls != null) calls.clear(); + if (manager != null) manager.close(); + if (account != null) account.close(); + } + + private void events(String json) throws Exception { + var method = CallManager.class.getDeclaredMethod("readControlEvents", CallManager.CallState.class, InputStream.class); + method.setAccessible(true); + method.invoke(manager, state, new ByteArrayInputStream((json + "\n").getBytes(StandardCharsets.UTF_8))); + } + + @Test + void replaysAcceptedNotificationReceivedBeforeReady() throws Exception { + manager.handleIncomingHangup(state.recipientId, state.callId, 1, HangupMessage.Type.ACCEPTED, 2); + assertEquals("", output.toString()); + events("{\"type\":\"ready\",\"signalingVersion\":2}"); + var message = MAPPER.readTree(output.toString()); + assertEquals("receivedHangup", message.path("type").asText()); + assertEquals(1, message.path("hangupType").asInt()); + assertEquals(2, message.path("deviceId").asInt()); + assertTrue(state.pendingRemoteNotifications.isEmpty()); + } + + @Test + void separateCapabilitiesDoNotLoseEarlyNotification() throws Exception { + manager.handleIncomingHangup(state.recipientId, state.callId, 1, HangupMessage.Type.DECLINED, 2); + events("{\"type\":\"ready\"}"); + assertEquals("", output.toString()); + events("{\"type\":\"signalingCapabilities\",\"version\":2}"); + assertEquals(2, MAPPER.readTree(output.toString()).path("hangupType").asInt()); + } + + @Test + void legacyCallEventResolvesPendingNotification() throws Exception { + manager.handleIncomingHangup(state.recipientId, state.callId, 1, HangupMessage.Type.ACCEPTED, 2); + events("{\"type\":\"ready\"}\n{\"type\":\"stateChange\",\"state\":\"Connected\"}"); + assertEquals(Boolean.FALSE, state.multiDeviceSignaling); + assertTrue(state.pendingRemoteNotifications.isEmpty()); + assertEquals("", output.toString()); + assertEquals(CallInfo.State.CONNECTED, state.state); + } + + @Test + void doesNotReplayNotificationsAfterCallRemoval() throws Exception { + manager.handleIncomingHangup(state.recipientId, state.callId, 1, HangupMessage.Type.ACCEPTED, 2); + manager.removeCall(state.callId); + events("{\"type\":\"ready\",\"signalingVersion\":2}"); + assertEquals("", output.toString()); + assertTrue(state.pendingRemoteNotifications.isEmpty()); + } + + @Test + void callRemovalWaitsForInFlightNotification() throws Exception { + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var registeredAtWrite = new AtomicBoolean(); + var failure = new AtomicReference(); + state.controlWriter = new PrintWriter(output, true) { + @Override + public void println(String line) { + entered.countDown(); + try { + if (!release.await(5, TimeUnit.SECONDS)) throw new AssertionError("write gate timed out"); + registeredAtWrite.set(calls.containsKey(state.callId)); + super.println(line); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + } + }; + manager.handleIncomingHangup(state.recipientId, state.callId, 1, HangupMessage.Type.ACCEPTED, 2); + var negotiation = new Thread(() -> { + try { + events("{\"type\":\"ready\",\"signalingVersion\":2}"); + } catch (Throwable e) { + failure.set(e); + } + }); + var cancellation = new Thread(() -> manager.removeCall(state.callId)); + negotiation.start(); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + cancellation.start(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (cancellation.getState() != Thread.State.BLOCKED && cancellation.isAlive() + && System.nanoTime() < deadline) { + Thread.onSpinWait(); + } + assertEquals(Thread.State.BLOCKED, cancellation.getState()); + assertTrue(calls.containsKey(state.callId)); + } finally { + release.countDown(); + negotiation.join(5000); + cancellation.join(5000); + } + assertFalse(negotiation.isAlive()); + assertFalse(cancellation.isAlive()); + assertNull(failure.get()); + assertTrue(registeredAtWrite.get()); + assertFalse(calls.containsKey(state.callId)); + assertEquals(CallInfo.State.ENDED, state.state); + assertTrue(state.pendingRemoteNotifications.isEmpty()); + } + + @Test + void readyNegotiatesBeforeCallEvents() throws Exception { + events("{\"type\":\"ready\",\"signalingVersion\":2}"); + manager.handleIncomingHangup(state.recipientId, state.callId, 2, HangupMessage.Type.NORMAL, 0); + var message = MAPPER.readTree(output.toString()); + assertEquals("receivedHangup", message.path("type").asText()); + assertEquals(2, message.path("senderDeviceId").asInt()); + assertEquals(CallInfo.State.CONNECTED, state.state); + assertTrue(calls.containsKey(state.callId)); + } + + @Test + void supportsSeparateCapabilityEvent() throws Exception { + events("{\"type\":\"ready\"}\n{\"type\":\"signalingCapabilities\",\"version\":2}"); + manager.handleIncomingBusy(state.recipientId, state.callId, 7); + var message = MAPPER.readTree(output.toString()); + assertEquals("receivedBusy", message.path("type").asText()); + assertEquals(7, message.path("senderDeviceId").asInt()); + assertEquals(CallInfo.State.CONNECTED, state.state); + } + + @Test + void oldTunnelDelegatesNormalHangupToLegacyCleanup() throws Exception { + events("{\"type\":\"ready\"}"); + manager.handleIncomingHangup(state.recipientId, state.callId, 1, HangupMessage.Type.NORMAL, 0); + assertEquals(state.callId, manager.endedCall); + assertEquals("remote_hangup", manager.endReason); + assertEquals("", output.toString()); + } + + @Test + void oldTunnelDelegatesBusyToLegacyCleanup() { + manager.handleIncomingBusy(state.recipientId, state.callId, 1); + assertEquals(state.callId, manager.endedCall); + assertEquals("remote_busy", manager.endReason); + } + + @Test + void oldTunnelIgnoresOtherDeviceNotification() { + manager.handleIncomingHangup(state.recipientId, state.callId, 2, HangupMessage.Type.ACCEPTED, 1); + assertEquals("", output.toString()); + assertEquals(CallInfo.State.CONNECTED, state.state); + } + + @Test + void ignoresWrongSenderAndUnknownCall() throws Exception { + events("{\"type\":\"ready\",\"signalingVersion\":2}"); + RecipientId stranger = TestRecipientId.createTestId(99999L); + manager.handleIncomingHangup(stranger, state.callId, 2, HangupMessage.Type.NORMAL, 0); + manager.handleIncomingBusy(stranger, state.callId, 2); + manager.handleIncomingHangup(state.recipientId, 42, 2, HangupMessage.Type.NORMAL, 0); + manager.handleIncomingBusy(state.recipientId, 42, 2); + assertEquals("", output.toString()); + assertEquals(CallInfo.State.CONNECTED, state.state); + } + + @Test + void preservesBroadcastAndExplicitTargetsWithLegacyFallback() throws Exception { + var method = CallManager.class.getDeclaredMethod("receiverDeviceId", CallManager.CallState.class, JsonNode.class); + method.setAccessible(true); + state.deviceId = 3; + assertEquals(3, method.invoke(null, state, MAPPER.readTree("{}"))); + assertNull(method.invoke(null, state, MAPPER.readTree("{\"receiverDeviceId\":null}"))); + assertEquals(7, method.invoke(null, state, MAPPER.readTree("{\"receiverDeviceId\":7}"))); + } + + @Test + void acceptedNotificationPreservesAnsweredDeviceAndBroadcast() throws Exception { + var method = CallManager.class.getDeclaredMethod("hangupMessage", long.class, String.class, int.class, Integer.class); + method.setAccessible(true); + var message = (SignalServiceCallMessage) method.invoke(null, -1L, "acceptedonanotherdevice", 1, null); + assertTrue(message.getDestinationDeviceId().isEmpty()); + var hangup = message.getHangupMessage().orElseThrow(); + assertEquals(HangupMessage.Type.ACCEPTED, hangup.getType()); + assertEquals(1, hangup.getDeviceId()); + assertEquals(-1L, hangup.getId()); + } + + @ParameterizedTest + @EnumSource(value = CallInfo.State.class, names = {"CONNECTED", "RECONNECTING"}) + void lateAnswerDoesNotReplaceEstablishedState(CallInfo.State established) throws Exception { + events("{\"type\":\"ready\",\"signalingVersion\":2}"); + state.state = established; + manager.handleIncomingAnswer(state.callId, 2, new byte[]{1}); + assertEquals(established, state.state); + assertNull(state.deviceId); + assertEquals(2, MAPPER.readTree(output.toString()).path("senderDeviceId").asInt()); + } + + private static class RecordingCallManager extends CallManager { + long endedCall; + String endReason; + + RecordingCallManager(Context context) { + super(context); + } + + @Override + void endCall(long callId, String reason) { + endedCall = callId; + endReason = reason; + } + } +}