From 9525dd3895904006641590caf65c8761e8eab744 Mon Sep 17 00:00:00 2001 From: Robert Nisipeanu Date: Tue, 22 Sep 2026 22:25:28 +0300 Subject: [PATCH] Retain early remote notifications until tunnel negotiation --- docs/CALL_TUNNEL.md | 6 ++- .../signal/manager/helper/CallManager.java | 41 +++++++++++++++---- .../helper/CallManagerSignalingTest.java | 40 ++++++++++++++++++ 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/docs/CALL_TUNNEL.md b/docs/CALL_TUNNEL.md index 017d524a..898a2312 100644 --- a/docs/CALL_TUNNEL.md +++ b/docs/CALL_TUNNEL.md @@ -52,7 +52,11 @@ 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. -The account's local device-ID fix is independent of this protocol extension. +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 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 29b9ba49..77eca18c 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,7 +276,7 @@ public class CallManager implements AutoCloseable { answerMsg.put("receiverIdentityKey", java.util.Base64.getEncoder().encodeToString(localIdentityKey)); sendControlMessage(state, writeJson(answerMsg)); - if (!state.multiDeviceSignaling) { + if (!Boolean.TRUE.equals(state.multiDeviceSignaling)) { state.deviceId = deviceId; } if (state.state != CallInfo.State.CONNECTED && state.state != CallInfo.State.RECONNECTING) { @@ -310,7 +310,13 @@ public class CallManager implements AutoCloseable { if (state == null || !state.recipientId.equals(sender)) { return; } - if (!state.multiDeviceSignaling) { + synchronized (state) { + 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"); } @@ -335,7 +341,7 @@ public class CallManager implements AutoCloseable { if (state == null || !state.recipientId.equals(sender)) { return; } - if (!state.multiDeviceSignaling) { + if (!Boolean.TRUE.equals(state.multiDeviceSignaling)) { endCall(callId, "remote_busy"); return; } @@ -500,6 +506,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; @@ -512,9 +529,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" -> { - state.multiDeviceSignaling = json.path("signalingVersion").asInt(1) >= 2; + if (json.has("signalingVersion")) { + setSignalingVersion(state, json.path("signalingVersion").asInt(1)); + } if (json.has("inputDeviceName")) { state.inputDeviceName = json.get("inputDeviceName").asText(); } @@ -527,7 +552,7 @@ public class CallManager implements AutoCloseable { state.outputDeviceName); } case "signalingCapabilities" -> { - state.multiDeviceSignaling = json.path("version").asInt(1) >= 2; + setSignalingVersion(state, json.path("version").asInt(1)); } case "sendOffer" -> { var opaqueB64 = json.get("opaque").asText(); @@ -553,7 +578,7 @@ public class CallManager implements AutoCloseable { var hangupType = json.has("hangupType") ? json.get("hangupType").asText("normal") : "normal"; - if (!state.multiDeviceSignaling && hangupType.contains("onanotherdevice")) { + if (!Boolean.TRUE.equals(state.multiDeviceSignaling) && hangupType.contains("onanotherdevice")) { break; } logSendMessageResult(sendHangupViaSignal(state, hangupType, @@ -818,7 +843,9 @@ public class CallManager implements AutoCloseable { volatile CallInfo.State state; final RecipientId recipientId; volatile Integer deviceId; - volatile boolean multiDeviceSignaling; + // 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/test/java/org/asamk/signal/manager/helper/CallManagerSignalingTest.java b/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerSignalingTest.java index 3a16f349..e43c3d9e 100644 --- a/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerSignalingTest.java +++ b/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerSignalingTest.java @@ -70,6 +70,46 @@ class CallManagerSignalingTest { 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); + calls.clear(); + events("{\"type\":\"ready\",\"signalingVersion\":2}"); + assertEquals("", output.toString()); + assertTrue(state.pendingRemoteNotifications.isEmpty()); + } + @Test void readyNegotiatesBeforeCallEvents() throws Exception { events("{\"type\":\"ready\",\"signalingVersion\":2}");