Retain early remote notifications until tunnel negotiation

This commit is contained in:
Robert Nisipeanu 2026-09-22 22:25:28 +03:00
parent dec7b8207a
commit 9525dd3895
No known key found for this signature in database
GPG Key ID: 617A3B9A8F291466
3 changed files with 79 additions and 8 deletions

View File

@ -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

View File

@ -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<Runnable> pendingRemoteNotifications = new ArrayList<>();
final boolean isOutgoing;
volatile String inputDeviceName;
volatile String outputDeviceName;

View File

@ -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}");