diff --git a/docs/CALL_TUNNEL.md b/docs/CALL_TUNNEL.md index b116c0b9..b51bc5dd 100644 --- a/docs/CALL_TUNNEL.md +++ b/docs/CALL_TUNNEL.md @@ -61,13 +61,13 @@ The first line written to the tunnel's stdin: } ``` -| Field | Type | Description | -|-------|------|-------------| -| `call_id` | unsigned 64-bit integer | Call identifier (use unsigned representation) | -| `is_outgoing` | boolean | Whether this is an outgoing call | -| `local_device_id` | integer | Signal device ID | -| `input_device_name` | string (optional) | Requested input audio device name | -| `output_device_name` | string (optional) | Requested output audio device name | +| Field | Type | Description | +|----------------------|-------------------------|-----------------------------------------------| +| `call_id` | unsigned 64-bit integer | Call identifier (use unsigned representation) | +| `is_outgoing` | boolean | Whether this is an outgoing call | +| `local_device_id` | integer | Signal device ID | +| `input_device_name` | string (optional) | Requested input audio device name | +| `output_device_name` | string (optional) | Requested output audio device name | If `input_device_name` or `output_device_name` are omitted, the tunnel chooses default names. On Linux, these are per-call unique names (e.g., @@ -84,33 +84,39 @@ lines are control messages. ### signal-cli -> Tunnel (stdin) -| Type | When | Fields | -|------|------|--------| -| `createOutgoingCall` | Outgoing call setup | `callId`, `peerId` | -| `proceed` | After offer/receivedOffer | `callId`, `hideIp`, `iceServers` | -| `receivedOffer` | Incoming call | `callId`, `peerId`, `opaque`, `age`, `senderDeviceId`, `senderIdentityKey`, `receiverIdentityKey` | -| `receivedAnswer` | Outgoing call answered | `opaque`, `senderDeviceId`, `senderIdentityKey`, `receiverIdentityKey` | -| `receivedIce` | ICE candidates arrive | `candidates` (array of base64 opaque blobs) | -| `accept` | User accepts incoming call | *(none)* | -| `hangup` | End the call | *(none)* | +| Type | When | Fields | +|----------------------|----------------------------|---------------------------------------------------------------------------------------------------| +| `createOutgoingCall` | Outgoing call setup | `callId`, `peerId` | +| `proceed` | After offer/receivedOffer | `callId`, `hideIp`, `iceServers` | +| `receivedOffer` | Incoming call | `callId`, `peerId`, `opaque`, `age`, `senderDeviceId`, `senderIdentityKey`, `receiverIdentityKey` | +| `receivedAnswer` | Outgoing call answered | `opaque`, `senderDeviceId`, `senderIdentityKey`, `receiverIdentityKey` | +| `receivedIce` | ICE candidates arrive | `candidates` (array of base64 opaque blobs) | +| `accept` | User accepts incoming call | *(none)* | +| `hangup` | End the call | *(none)* | ### Tunnel -> signal-cli (stdout) -| Type | When | Fields | -|------|------|--------| -| `ready` | Control socket bound, audio devices created | `inputDeviceName`, `outputDeviceName` | -| `sendOffer` | Tunnel generated an offer | `callId`, `opaque`, `callMediaType` | -| `sendAnswer` | Tunnel generated an answer | `callId`, `opaque` | -| `sendIce` | ICE candidates gathered | `callId`, `candidates` (array of `{"opaque":"..."}`) | -| `sendHangup` | Tunnel wants to hang up | `callId`, `hangupType` | -| `sendBusy` | Line is busy | `callId` | -| `stateChange` | Call state transition | `state`, `reason` (optional) | -| `error` | Something went wrong | `message` | +| Type | When | Fields | +|---------------|---------------------------------------------|------------------------------------------------------| +| `ready` | Control socket bound, audio devices created | `inputDeviceName`, `outputDeviceName` | +| `sendOffer` | Tunnel generated an offer | `callId`, `opaque`, `callMediaType` | +| `sendAnswer` | Tunnel generated an answer | `callId`, `opaque` | +| `sendIce` | ICE candidates gathered | `callId`, `candidates` (array of `{"opaque":"..."}`) | +| `sendHangup` | Tunnel wants to hang up | `callId`, `hangupType` | +| `sendBusy` | Line is busy | `callId` | +| `stateChange` | Call state transition | `state`, `reason` (optional) | +| `error` | Something went wrong | `message` | Opaque blobs and identity keys are base64-encoded. ICE servers use the format: ```json -{"urls":["turn:example.com"],"username":"u","password":"p"} +{ + "urls": [ + "turn:example.com" + ], + "username": "u", + "password": "p" +} ``` --- @@ -191,7 +197,6 @@ signal-cli signal-call-tunnel Remote Phone ### JSON-RPC client perspective An external application (bot, UI, test script) interacts via JSON-RPC only. -It never touches the control socket directly. **Important:** Call event notifications are not sent by default. Clients must call `subscribeCallEvents` before initiating or receiving calls. Without this, diff --git a/lib/src/main/java/org/asamk/signal/manager/Manager.java b/lib/src/main/java/org/asamk/signal/manager/Manager.java index cff89612..fda3a323 100644 --- a/lib/src/main/java/org/asamk/signal/manager/Manager.java +++ b/lib/src/main/java/org/asamk/signal/manager/Manager.java @@ -4,6 +4,8 @@ import com.google.i18n.phonenumbers.PhoneNumberUtil; import org.asamk.signal.manager.api.AlreadyReceivingException; import org.asamk.signal.manager.api.AttachmentInvalidException; +import org.asamk.signal.manager.api.CallInfo; +import org.asamk.signal.manager.api.CallOffer; import org.asamk.signal.manager.api.CaptchaRejectedException; import org.asamk.signal.manager.api.CaptchaRequiredException; import org.asamk.signal.manager.api.Configuration; @@ -37,11 +39,13 @@ import org.asamk.signal.manager.api.ReceiveConfig; import org.asamk.signal.manager.api.Recipient; import org.asamk.signal.manager.api.RecipientIdentifier; import org.asamk.signal.manager.api.SendGroupMessageResults; +import org.asamk.signal.manager.api.SendMessageResult; import org.asamk.signal.manager.api.SendMessageResults; import org.asamk.signal.manager.api.StickerPack; import org.asamk.signal.manager.api.StickerPackId; import org.asamk.signal.manager.api.StickerPackInvalidException; import org.asamk.signal.manager.api.StickerPackUrl; +import org.asamk.signal.manager.api.TurnServer; import org.asamk.signal.manager.api.TypingAction; import org.asamk.signal.manager.api.UnregisteredRecipientException; import org.asamk.signal.manager.api.UpdateGroup; @@ -64,10 +68,6 @@ import java.util.Map; import java.util.Optional; import java.util.Set; -import org.asamk.signal.manager.api.CallInfo; -import org.asamk.signal.manager.api.CallOffer; -import org.asamk.signal.manager.api.TurnServer; - public interface Manager extends Closeable { static boolean isValidNumber(final String e164Number, final String countryCode) { @@ -425,17 +425,32 @@ public interface Manager extends Closeable { void hangupCall(long callId) throws IOException; - void rejectCall(long callId) throws IOException; + SendMessageResult rejectCall(long callId) throws IOException; List listActiveCalls(); - void sendCallOffer(RecipientIdentifier.Single recipient, CallOffer offer) throws IOException, UnregisteredRecipientException; + void sendCallOffer( + RecipientIdentifier.Single recipient, + CallOffer offer + ) throws IOException, UnregisteredRecipientException; - void sendCallAnswer(RecipientIdentifier.Single recipient, long callId, byte[] answerOpaque) throws IOException, UnregisteredRecipientException; + void sendCallAnswer( + RecipientIdentifier.Single recipient, + long callId, + byte[] answerOpaque + ) throws IOException, UnregisteredRecipientException; - void sendIceUpdate(RecipientIdentifier.Single recipient, long callId, List iceCandidates) throws IOException, UnregisteredRecipientException; + void sendIceUpdate( + RecipientIdentifier.Single recipient, + long callId, + List iceCandidates + ) throws IOException, UnregisteredRecipientException; - void sendHangup(RecipientIdentifier.Single recipient, long callId, MessageEnvelope.Call.Hangup.Type type) throws IOException, UnregisteredRecipientException; + void sendHangup( + RecipientIdentifier.Single recipient, + long callId, + MessageEnvelope.Call.Hangup.Type type + ) throws IOException, UnregisteredRecipientException; void sendBusy(RecipientIdentifier.Single recipient, long callId) throws IOException, UnregisteredRecipientException; 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 b5d4eda5..d4fd6bc7 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 @@ -1,30 +1,40 @@ package org.asamk.signal.manager.helper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.api.CallInfo; import org.asamk.signal.manager.api.MessageEnvelope; -import org.asamk.signal.manager.api.RecipientIdentifier; import org.asamk.signal.manager.api.TurnServer; -import org.asamk.signal.manager.api.UnregisteredRecipientException; import org.asamk.signal.manager.internal.SignalDependencies; import org.asamk.signal.manager.storage.SignalAccount; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; +import org.asamk.signal.manager.storage.recipients.RecipientId; +import org.asamk.signal.manager.util.Utils; +import org.signal.libsignal.protocol.IdentityKey; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.whispersystems.signalservice.api.messages.SendMessageResult; +import org.whispersystems.signalservice.api.messages.calls.AnswerMessage; +import org.whispersystems.signalservice.api.messages.calls.BusyMessage; +import org.whispersystems.signalservice.api.messages.calls.HangupMessage; +import org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage; +import org.whispersystems.signalservice.api.messages.calls.OfferMessage; +import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; +import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.io.PrintWriter; +import java.math.BigInteger; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.security.SecureRandom; -import java.math.BigInteger; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -32,6 +42,10 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static org.asamk.signal.manager.util.Utils.callIdUnsigned; +import static org.asamk.signal.manager.util.Utils.handleResponseException; /** * Manages active voice calls: tracks state, spawns/monitors the signal-call-tunnel @@ -69,7 +83,7 @@ public class CallManager implements AutoCloseable { } private void fireCallEvent(CallState state, String reason) { - var callInfo = state.toCallInfo(); + var callInfo = state.toCallInfo(account.getRecipientAddressResolver()); for (var listener : callEventListeners) { try { listener.handleCallEvent(callInfo, reason); @@ -80,22 +94,16 @@ public class CallManager implements AutoCloseable { } public CallInfo startOutgoingCall( - final RecipientIdentifier.Single recipient - ) throws IOException, UnregisteredRecipientException { + final RecipientId recipientId + ) throws IOException { var callId = generateCallId(); - var recipientId = context.getRecipientHelper().resolveRecipient(recipient); - var recipientAddress = context.getRecipientHelper() - .resolveSignalServiceAddress(recipientId) - .getServiceId(); - var recipientApiAddress = account.getRecipientAddressResolver() - .resolveRecipientAddress(recipientId) - .toApiRecipientAddress(); + var recipientAddress = account.getRecipientAddressResolver().resolveRecipientAddress(recipientId); - var state = new CallState(callId, - CallInfo.State.RINGING_OUTGOING, - recipientApiAddress, - recipient, - true); + var state = new CallState(callId, CallInfo.State.RINGING_OUTGOING, recipientId, null, true); + logger.debug("Starting outgoing call {} to {} (recipientId: {})", + callIdUnsigned(callId), + recipientAddress, + recipientId); activeCalls.put(callId, state); fireCallEvent(state, null); @@ -108,7 +116,7 @@ public class CallManager implements AutoCloseable { // Send createOutgoingCall + proceed via control channel var createMsg = mapper.createObjectNode(); createMsg.put("type", "createOutgoingCall"); - createMsg.put("callId", callIdUnsigned(callId)); + createMsg.put("callId", Utils.callIdUnsigned(callId)); createMsg.put("peerId", recipientAddress.toString()); sendControlMessage(state, writeJson(createMsg)); sendProceed(state, callId, turnServers); @@ -116,17 +124,18 @@ public class CallManager implements AutoCloseable { // Schedule ring timeout scheduler.schedule(() -> handleRingTimeout(callId), RING_TIMEOUT_MS, TimeUnit.MILLISECONDS); - logger.info("Started outgoing call {} to {}", callId, recipient); - return state.toCallInfo(); + logger.debug("Started outgoing call {} to {}", callIdUnsigned(callId), recipientAddress); + return state.toCallInfo(account.getRecipientAddressResolver()); } public CallInfo acceptIncomingCall(final long callId) throws IOException { - var state = activeCalls.get(callId); - if (state == null) { - throw new IOException("No active call with id " + callId); - } + final var state = getActiveCall(callId); if (state.state != CallInfo.State.RINGING_INCOMING) { - throw new IOException("Call " + callId + " is not in RINGING_INCOMING state (current: " + state.state + ")"); + throw new IOException("Call " + + callId + + " is not in RINGING_INCOMING state (current: " + + state.state + + ")"); } // Defer the accept until the tunnel reports Ringing state. @@ -139,46 +148,34 @@ public class CallManager implements AutoCloseable { state.state = CallInfo.State.CONNECTING; fireCallEvent(state, null); - logger.info("Accepted incoming call {}", callId); - return state.toCallInfo(); + logger.debug("Accepted incoming call {}", callIdUnsigned(callId)); + return state.toCallInfo(account.getRecipientAddressResolver()); } public void hangupCall(final long callId) throws IOException { - var state = activeCalls.get(callId); - if (state == null) { - throw new IOException("No active call with id " + callId); - } + getActiveCall(callId); endCall(callId, "local_hangup"); } - public void rejectCall(final long callId) throws IOException { - var state = activeCalls.get(callId); - if (state == null) { - throw new IOException("No active call with id " + callId); - } + public SendMessageResult rejectCall(final long callId) throws IOException { + final var callState = getActiveCall(callId); - try { - var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier); - var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); - var busyMessage = new org.whispersystems.signalservice.api.messages.calls.BusyMessage(callId); - var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forBusy( - busyMessage, null); - dependencies.getMessageSender().sendCallMessage(address, null, callMessage); - } catch (Exception e) { - logger.warn("Failed to send busy message for call {}", callId, e); - } + final var result = sendBusyMessage(callState.callId, callState.recipientId, callState.deviceId); endCall(callId, "rejected"); + return result; } public List listActiveCalls() { - return activeCalls.values().stream().map(CallState::toCallInfo).toList(); + return activeCalls.values() + .stream() + .map((CallState callState) -> callState.toCallInfo(account.getRecipientAddressResolver())) + .toList(); } public List getTurnServers() throws IOException { try { - var result = dependencies.getCallingApi().getTurnServerInfo(); - var turnServerList = result.successOrThrow(); + var turnServerList = handleResponseException(dependencies.getCallingApi().getTurnServerInfo()); return turnServerList.stream() .map(info -> new TurnServer(info.getUsername(), info.getPassword(), info.getUrls())) .toList(); @@ -191,47 +188,34 @@ public class CallManager implements AutoCloseable { // --- Incoming call message handling --- public void handleIncomingOffer( - final org.asamk.signal.manager.storage.recipients.RecipientId senderId, + final RecipientId recipientId, + final int deviceId, final long callId, final MessageEnvelope.Call.Offer.Type type, final byte[] opaque ) { if (callEventListeners.isEmpty()) { - logger.debug("Ignoring incoming offer for call {}: no call event listeners registered", callId); - try { - var address = context.getRecipientHelper().resolveSignalServiceAddress(senderId); - var busyMessage = new org.whispersystems.signalservice.api.messages.calls.BusyMessage(callId); - var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forBusy( - busyMessage, null); - dependencies.getMessageSender().sendCallMessage(address, null, callMessage); - } catch (Exception e) { - logger.warn("Failed to send busy for unhandled call {}", callId, e); + logger.debug("Ignoring incoming offer for call {}: no call event listeners registered", + callIdUnsigned(callId)); + + final var result = sendBusyMessage(callId, recipientId, deviceId); + if (!result.isSuccess()) { + logger.warn("Failed to send busy for unhandled call {}", callIdUnsigned(callId)); } return; } var senderAddress = account.getRecipientAddressResolver() - .resolveRecipientAddress(senderId) + .resolveRecipientAddress(recipientId) .toApiRecipientAddress(); - RecipientIdentifier.Single senderIdentifier; - if (senderAddress.number().isPresent()) { - senderIdentifier = new RecipientIdentifier.Number(senderAddress.number().get()); - } else if (senderAddress.uuid().isPresent()) { - senderIdentifier = new RecipientIdentifier.Uuid(senderAddress.uuid().get()); - } else { - logger.warn("Cannot identify sender for call {}", callId); - return; - } - logger.debug("Incoming offer opaque ({} bytes)", opaque == null ? 0 : opaque.length); - var state = new CallState(callId, - CallInfo.State.RINGING_INCOMING, + var state = new CallState(callId, CallInfo.State.RINGING_INCOMING, recipientId, deviceId, false); + logger.debug("Starting incoming call {} from {} (recipientId: {})", + callIdUnsigned(callId), senderAddress, - senderIdentifier, - false); - state.rawOfferOpaque = opaque; + recipientId); activeCalls.put(callId, state); // Spawn call tunnel binary immediately @@ -239,7 +223,7 @@ public class CallManager implements AutoCloseable { // Get identity keys for the receivedOffer message // Use raw 32-byte Curve25519 public key (without 0x05 DJB prefix) to match Signal Android - byte[] localIdentityKey = getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey().serialize()); + byte[] localIdentityKey = getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey()); byte[] remoteIdentityKey = getRemoteIdentityKey(state); // Fetch TURN servers @@ -247,16 +231,16 @@ public class CallManager implements AutoCloseable { try { turnServers = getTurnServers(); } catch (IOException e) { - logger.warn("Failed to get TURN servers for incoming call {}", callId, e); + logger.warn("Failed to get TURN servers for incoming call {}", callIdUnsigned(callId), e); turnServers = List.of(); } // Send receivedOffer to subprocess var offerMsg = mapper.createObjectNode(); offerMsg.put("type", "receivedOffer"); - offerMsg.put("callId", callIdUnsigned(callId)); + offerMsg.put("callId", Utils.callIdUnsigned(callId)); offerMsg.put("peerId", senderAddress.toString()); - offerMsg.put("senderDeviceId", 1); + offerMsg.put("senderDeviceId", deviceId); offerMsg.put("opaque", java.util.Base64.getEncoder().encodeToString(opaque)); offerMsg.put("age", 0); offerMsg.put("senderIdentityKey", java.util.Base64.getEncoder().encodeToString(remoteIdentityKey)); @@ -271,40 +255,41 @@ public class CallManager implements AutoCloseable { // Schedule ring timeout scheduler.schedule(() -> handleRingTimeout(callId), RING_TIMEOUT_MS, TimeUnit.MILLISECONDS); - logger.info("Incoming call {} from {}", callId, senderAddress); + logger.debug("Incoming call {} from {}", callIdUnsigned(callId), senderAddress); } - public void handleIncomingAnswer(final long callId, final byte[] opaque) { + public void handleIncomingAnswer(final long callId, final int deviceId, final byte[] opaque) { var state = activeCalls.get(callId); if (state == null) { - logger.warn("Received answer for unknown call {}", callId); + logger.warn("Received answer for unknown call {}", callIdUnsigned(callId)); return; } // Get identity keys // Use raw 32-byte Curve25519 public key (without 0x05 DJB prefix) to match Signal Android - byte[] localIdentityKey = getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey().serialize()); + byte[] localIdentityKey = getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey()); byte[] remoteIdentityKey = getRemoteIdentityKey(state); // Forward raw opaque to subprocess var answerMsg = mapper.createObjectNode(); answerMsg.put("type", "receivedAnswer"); answerMsg.put("opaque", java.util.Base64.getEncoder().encodeToString(opaque)); - answerMsg.put("senderDeviceId", 1); + answerMsg.put("senderDeviceId", deviceId); answerMsg.put("senderIdentityKey", java.util.Base64.getEncoder().encodeToString(remoteIdentityKey)); answerMsg.put("receiverIdentityKey", java.util.Base64.getEncoder().encodeToString(localIdentityKey)); sendControlMessage(state, writeJson(answerMsg)); + state.deviceId = deviceId; state.state = CallInfo.State.CONNECTING; fireCallEvent(state, null); - logger.info("Received answer for call {}", callId); + logger.debug("Received answer for call {}", callIdUnsigned(callId)); } public void handleIncomingIceCandidate(final long callId, final byte[] opaque) { var state = activeCalls.get(callId); if (state == null) { - logger.debug("Received ICE candidate for unknown call {}", callId); + logger.debug("Received ICE candidate for unknown call {}", callIdUnsigned(callId)); return; } @@ -314,7 +299,7 @@ public class CallManager implements AutoCloseable { var candidates = iceMsg.putArray("candidates"); candidates.add(java.util.Base64.getEncoder().encodeToString(opaque)); sendControlMessage(state, writeJson(iceMsg)); - logger.debug("Forwarded ICE candidate to tunnel for call {}", callId); + logger.debug("Forwarded ICE candidate to tunnel for call {}", callIdUnsigned(callId)); } public void handleIncomingHangup(final long callId) { @@ -333,9 +318,25 @@ public class CallManager implements AutoCloseable { // --- Internal helpers --- + private CallState getActiveCall(final long callId) throws IOException { + var state = activeCalls.get(callId); + if (state == null) { + throw new IOException("No active call with id " + callIdUnsigned(callId)); + } + return state; + } + + private SendMessageResult sendBusyMessage(final long callId, final RecipientId recipientId, final int deviceId) { + var busyMessage = new BusyMessage(callId); + var callMessage = SignalServiceCallMessage.forBusy(busyMessage, deviceId); + return context.getSendHelper().sendCallMessage(callMessage, recipientId); + } + private void sendControlMessage(CallState state, String json) { if (state.controlWriter == null) { - logger.debug("Queueing control message for call {} (not yet connected): {}", state.callId, json); + logger.debug("Queueing control message for call {} (not yet connected): {}", + callIdUnsigned(state.callId), + json); state.pendingControlMessages.add(json); return; } @@ -345,7 +346,7 @@ public class CallManager implements AutoCloseable { private void sendProceed(CallState state, long callId, List turnServers) { var proceedMsg = mapper.createObjectNode(); proceedMsg.put("type", "proceed"); - proceedMsg.put("callId", callIdUnsigned(callId)); + proceedMsg.put("callId", Utils.callIdUnsigned(callId)); proceedMsg.put("hideIp", false); var iceServers = proceedMsg.putArray("iceServers"); for (var ts : turnServers) { @@ -379,8 +380,7 @@ public class CallManager implements AutoCloseable { stdinStream.flush(); // stdin is the control write channel - state.controlWriter = new PrintWriter( - new OutputStreamWriter(stdinStream, StandardCharsets.UTF_8), true); + state.controlWriter = new PrintWriter(new OutputStreamWriter(stdinStream, StandardCharsets.UTF_8), true); // Flush any pending control messages for (var msg : state.pendingControlMessages) { @@ -392,17 +392,17 @@ public class CallManager implements AutoCloseable { sendAcceptIfReady(state); // Read control events from subprocess stdout - Thread.ofVirtual().name("control-read-" + state.callId).start(() -> { - readControlEvents(state, process.getInputStream()); - }); + Thread.ofVirtual() + .name("control-read-" + callIdUnsigned(state.callId)) + .start(() -> readControlEvents(state, process.getInputStream())); // Drain subprocess stderr to prevent pipe buffer deadlock - Thread.ofVirtual().name("tunnel-stderr-" + state.callId).start(() -> { - try (var reader = new BufferedReader( - new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) { + Thread.ofVirtual().name("tunnel-stderr-" + callIdUnsigned(state.callId)).start(() -> { + try (var reader = new BufferedReader(new InputStreamReader(process.getErrorStream(), + StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { - logger.debug("[tunnel-{}] {}", state.callId, line); + logger.debug("[tunnel-{}] {}", callIdUnsigned(state.callId), line); } } catch (IOException ignored) { } @@ -410,15 +410,15 @@ public class CallManager implements AutoCloseable { // Monitor process exit process.onExit().thenAcceptAsync(p -> { - logger.info("Tunnel for call {} exited with code {}", state.callId, p.exitValue()); + logger.debug("Tunnel for call {} exited with code {}", callIdUnsigned(state.callId), p.exitValue()); if (activeCalls.containsKey(state.callId)) { endCall(state.callId, "tunnel_exit"); } }); - logger.info("Spawned signal-call-tunnel for call {}", state.callId); + logger.debug("Spawned signal-call-tunnel for call {}", callIdUnsigned(state.callId)); } catch (Exception e) { - logger.error("Failed to spawn tunnel for call {}", state.callId, e); + logger.error("Failed to spawn tunnel for call {}", callIdUnsigned(state.callId), e); endCall(state.callId, "tunnel_spawn_error"); } } @@ -461,20 +461,19 @@ public class CallManager implements AutoCloseable { private String buildConfig(CallState state) { var config = mapper.createObjectNode(); - config.put("call_id", callIdUnsigned(state.callId)); + config.put("call_id", Utils.callIdUnsigned(state.callId)); config.put("is_outgoing", state.isOutgoing); config.put("local_device_id", 1); return writeJson(config); } private void readControlEvents(CallState state, java.io.InputStream inputStream) { - try (var reader = new BufferedReader( - new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { + try (var reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { line = line.trim(); if (line.isEmpty()) continue; - logger.debug("Control event for call {}: {}", state.callId, line); + logger.debug("Control event for call {}: {}", callIdUnsigned(state.callId), line); try { var json = mapper.readTree(line); @@ -489,17 +488,19 @@ public class CallManager implements AutoCloseable { state.outputDeviceName = json.get("outputDeviceName").asText(); } logger.debug("Tunnel ready for call {}: input={}, output={}", - state.callId, state.inputDeviceName, state.outputDeviceName); + callIdUnsigned(state.callId), + state.inputDeviceName, + state.outputDeviceName); } case "sendOffer" -> { var opaqueB64 = json.get("opaque").asText(); var opaque = java.util.Base64.getDecoder().decode(opaqueB64); - sendOfferViaSignal(state, opaque); + logSendMessageResult(sendOfferViaSignal(state, opaque)); } case "sendAnswer" -> { var opaqueB64 = json.get("opaque").asText(); var opaque = java.util.Base64.getDecoder().decode(opaqueB64); - sendAnswerViaSignal(state, opaque); + logSendMessageResult(sendAnswerViaSignal(state, opaque)); } case "sendIce" -> { var candidatesArr = json.get("candidates"); @@ -507,22 +508,24 @@ public class CallManager implements AutoCloseable { for (var c : candidatesArr) { opaqueList.add(java.util.Base64.getDecoder().decode(c.get("opaque").asText())); } - sendIceViaSignal(state, opaqueList); + logSendMessageResult(sendIceViaSignal(state, opaqueList)); } case "sendHangup" -> { // RingRTC wants us to send a hangup message via Signal protocol. // This is NOT a local state change — local state is handled by stateChange events. - var hangupType = json.has("hangupType") ? json.get("hangupType").asText("normal") : "normal"; + 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 { - sendHangupViaSignal(state, hangupType); + logSendMessageResult(sendHangupViaSignal(state, hangupType)); } } case "sendBusy" -> { - sendBusyViaSignal(state); + logSendMessageResult(sendBusyViaSignal(state)); } case "stateChange" -> { var ringrtcState = json.get("state").asText(); @@ -531,19 +534,23 @@ public class CallManager implements AutoCloseable { } case "error" -> { var message = json.has("message") ? json.get("message").asText("unknown") : "unknown"; - logger.error("Tunnel error for call {}: {}", state.callId, message); + logger.error("Tunnel error for call {}: {}", callIdUnsigned(state.callId), message); endCall(state.callId, "tunnel_error"); } default -> { - logger.debug("Unknown control event type '{}' for call {}", type, state.callId); + logger.debug("Unknown control event type '{}' for call {}", + type, + callIdUnsigned(state.callId)); } } } catch (Exception e) { - logger.warn("Failed to parse control event JSON for call {}: {}", state.callId, e.getMessage()); + logger.warn("Failed to parse control event JSON for call {}: {}", + callIdUnsigned(state.callId), + e.getMessage()); } } } catch (IOException e) { - logger.debug("Control read ended for call {}: {}", state.callId, e.getMessage()); + logger.debug("Control read ended for call {}: {}", callIdUnsigned(state.callId), e.getMessage()); } } @@ -573,114 +580,97 @@ public class CallManager implements AutoCloseable { fireCallEvent(state, reason); } + public static void logSendMessageResult(SendMessageResult result) { + var identifier = result.getAddress().getIdentifier(); + if (result.getProofRequiredFailure() != null) { + final var failure = result.getProofRequiredFailure(); + logger.warn( + "CAPTCHA proof required for sending to \"{}\", available options \"{}\" with challenge token \"{}\", or wait \"{}\" seconds.\n", + identifier, + failure.getOptions() + .stream() + .map(ProofRequiredException.Option::toString) + .collect(Collectors.joining(", ")), + failure.getToken(), + failure.getRetryAfterSeconds()); + } else if (result.isNetworkFailure()) { + logger.warn("Network failure for \"{}\"", identifier); + } else if (result.getRateLimitFailure() != null) { + logger.warn("Rate limit failure for \"{}\"", identifier); + } else if (result.isUnregisteredFailure()) { + logger.warn("Unregistered user \"{}\"", identifier); + } else if (result.getIdentityFailure() != null) { + logger.warn("Untrusted Identity for \"{}\"", identifier); + } + } + private void sendAcceptIfReady(CallState state) { if (state.acceptPending && state.tunnelRinging && state.controlWriter != null) { state.acceptPending = false; - logger.debug("Sending deferred accept for call {}", state.callId); + logger.debug("Sending deferred accept for call {}", callIdUnsigned(state.callId)); var acceptMsg = mapper.createObjectNode(); acceptMsg.put("type", "accept"); state.controlWriter.println(writeJson(acceptMsg)); } } - private void sendOfferViaSignal(CallState state, byte[] opaque) { - try { - var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier); - var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); - var offerMessage = new org.whispersystems.signalservice.api.messages.calls.OfferMessage(state.callId, - org.whispersystems.signalservice.api.messages.calls.OfferMessage.Type.AUDIO_CALL, - opaque); - var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forOffer( - offerMessage, null); - dependencies.getMessageSender().sendCallMessage(address, null, callMessage); - logger.info("Sent offer via Signal for call {}", state.callId); - } catch (Exception e) { - logger.warn("Failed to send offer for call {}", state.callId, e); - } + private SendMessageResult sendOfferViaSignal(CallState state, byte[] opaque) { + var offerMessage = new OfferMessage(state.callId, OfferMessage.Type.AUDIO_CALL, opaque); + var callMessage = SignalServiceCallMessage.forOffer(offerMessage, state.deviceId); + final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId); + logger.debug("Sent offer via Signal for call {}", callIdUnsigned(state.callId)); + return result; } - private void sendAnswerViaSignal(CallState state, byte[] opaque) { - try { - var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier); - var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); - var answerMessage = new org.whispersystems.signalservice.api.messages.calls.AnswerMessage(state.callId, opaque); - var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forAnswer( - answerMessage, null); - dependencies.getMessageSender().sendCallMessage(address, null, callMessage); - logger.info("Sent answer via Signal for call {}", state.callId); - } catch (Exception e) { - logger.warn("Failed to send answer for call {}", state.callId, e); - } + private SendMessageResult sendAnswerViaSignal(CallState state, byte[] opaque) { + var answerMessage = new AnswerMessage(state.callId, opaque); + var callMessage = SignalServiceCallMessage.forAnswer(answerMessage, state.deviceId); + final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId); + logger.debug("Sent answer via Signal for call {}", callIdUnsigned(state.callId)); + return result; } - private void sendIceViaSignal(CallState state, List opaqueList) { - try { - var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier); - var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); - var iceUpdates = opaqueList.stream() - .map(opaque -> new org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage( - state.callId, opaque)) - .toList(); - var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forIceUpdates( - iceUpdates, null); - dependencies.getMessageSender().sendCallMessage(address, null, callMessage); - logger.info("Sent {} ICE candidates via Signal for call {}", opaqueList.size(), state.callId); - } catch (Exception e) { - logger.warn("Failed to send ICE for call {}", state.callId, e); - } + private SendMessageResult sendIceViaSignal(CallState state, List opaqueList) { + var iceUpdates = opaqueList.stream().map(opaque -> new IceUpdateMessage(state.callId, opaque)).toList(); + var callMessage = SignalServiceCallMessage.forIceUpdates(iceUpdates, state.deviceId); + 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 void sendBusyViaSignal(CallState state) { - try { - var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier); - var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); - var busyMessage = new org.whispersystems.signalservice.api.messages.calls.BusyMessage(state.callId); - var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forBusy( - busyMessage, null); - dependencies.getMessageSender().sendCallMessage(address, null, callMessage); - } catch (Exception e) { - logger.warn("Failed to send busy for call {}", state.callId, e); - } + private SendMessageResult sendBusyViaSignal(CallState state) { + var busyMessage = new BusyMessage(state.callId); + var callMessage = SignalServiceCallMessage.forBusy(busyMessage, state.deviceId); + return context.getSendHelper().sendCallMessage(callMessage, state.recipientId); } - private void sendHangupViaSignal(CallState state, String hangupType) { - try { - var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier); - var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); - var type = switch (hangupType) { - case "accepted", "acceptedonanotherdevice" -> - org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.ACCEPTED; - case "declined", "declinedonanotherdevice" -> - org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.DECLINED; - case "busy", "busyonanotherdevice" -> - org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.BUSY; - default -> org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.NORMAL; - }; - var hangupMessage = new org.whispersystems.signalservice.api.messages.calls.HangupMessage( - state.callId, type, 0); - var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forHangup( - hangupMessage, null); - dependencies.getMessageSender().sendCallMessage(address, null, callMessage); - logger.info("Sent hangup ({}) via Signal for call {}", hangupType, state.callId); - } catch (Exception e) { - logger.warn("Failed to send hangup for call {}", state.callId, e); - } + private SendMessageResult sendHangupViaSignal(CallState state, String hangupType) { + var type = switch (hangupType) { + case "accepted", "acceptedonanotherdevice" -> HangupMessage.Type.ACCEPTED; + case "declined", "declinedonanotherdevice" -> HangupMessage.Type.DECLINED; + case "busy", "busyonanotherdevice" -> HangupMessage.Type.BUSY; + default -> HangupMessage.Type.NORMAL; + }; + var hangupMessage = new HangupMessage(state.callId, type, state.deviceId); + var callMessage = SignalServiceCallMessage.forHangup(hangupMessage, state.deviceId); + final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId); + logger.debug("Sent hangup ({}) via Signal for call {}", hangupType, callIdUnsigned(state.callId)); + return result; } private byte[] getRemoteIdentityKey(CallState state) { try { - var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier); - var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); + var address = context.getRecipientHelper().resolveSignalServiceAddress(state.recipientId); var serviceId = address.getServiceId(); var identityInfo = account.getIdentityKeyStore().getIdentityInfo(serviceId); if (identityInfo != null) { - return getRawIdentityKeyBytes(identityInfo.getIdentityKey().serialize()); + return getRawIdentityKeyBytes(identityInfo.getIdentityKey()); } } catch (Exception e) { - logger.warn("Failed to get remote identity key for call {}", state.callId, e); + logger.warn("Failed to get remote identity key for call {}", callIdUnsigned(state.callId), e); } logger.warn("Using local identity key as fallback for remote identity key"); - return getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey().serialize()); + return getRawIdentityKeyBytes(account.getAciIdentityKeyPair().getPublicKey()); } /** @@ -688,18 +678,18 @@ public class CallManager implements AutoCloseable { * raw 32-byte Curve25519 public key. Signal Android does this via * WebRtcUtil.getPublicKeyBytes() before passing keys to RingRTC. */ - private static byte[] getRawIdentityKeyBytes(byte[] serializedKey) { + private static byte[] getRawIdentityKeyBytes(IdentityKey identityKey) { + var serializedKey = identityKey.serialize(); + return getRawIdentityKeyBytes(serializedKey); + } + + private static byte[] getRawIdentityKeyBytes(final byte[] serializedKey) { if (serializedKey.length == 33 && serializedKey[0] == 0x05) { return java.util.Arrays.copyOfRange(serializedKey, 1, serializedKey.length); } return serializedKey; } - /** Convert signed long call ID to unsigned BigInteger (tunnel binary expects u64). */ - private static BigInteger callIdUnsigned(long callId) { - return new BigInteger(Long.toUnsignedString(callId)); - } - private static String writeJson(ObjectNode node) { try { return mapper.writeValueAsString(node); @@ -714,21 +704,19 @@ public class CallManager implements AutoCloseable { state.state = CallInfo.State.ENDED; fireCallEvent(state, reason); - logger.info("Call {} ended: {}", callId, reason); + logger.debug("Call {} ended: {}", callIdUnsigned(callId), reason); // Send Signal protocol hangup to remote peer (unless they initiated the end) - if (!"remote_hangup".equals(reason) && !"rejected".equals(reason) && !"remote_busy".equals(reason) + if (!"remote_hangup".equals(reason) + && !"rejected".equals(reason) + && !"remote_busy".equals(reason) && !"ringrtc_hangup".equals(reason)) { - try { - var recipientId = context.getRecipientHelper().resolveRecipient(state.recipientIdentifier); - var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); - var hangupMessage = new org.whispersystems.signalservice.api.messages.calls.HangupMessage(callId, - org.whispersystems.signalservice.api.messages.calls.HangupMessage.Type.NORMAL, 0); - var callMessage = org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage.forHangup( - hangupMessage, null); - dependencies.getMessageSender().sendCallMessage(address, null, callMessage); - } catch (Exception e) { - logger.warn("Failed to send hangup to remote for call {}", callId, e); + var hangupMessage = new HangupMessage(callId, HangupMessage.Type.NORMAL, state.deviceId); + var callMessage = SignalServiceCallMessage.forHangup(hangupMessage, null); + final var result = context.getSendHelper().sendCallMessage(callMessage, state.recipientId); + if (!result.isSuccess()) { + logger.warn("Failed to send hangup to remote for call {}", callIdUnsigned(callId)); + logSendMessageResult(result); } } @@ -755,13 +743,13 @@ public class CallManager implements AutoCloseable { if (state == null) return; if (state.state == CallInfo.State.RINGING_INCOMING || state.state == CallInfo.State.RINGING_OUTGOING) { - logger.info("Call {} ring timeout", callId); + logger.debug("Call {} ring timeout", callIdUnsigned(callId)); endCall(callId, "ring_timeout"); } } private static long generateCallId() { - return new SecureRandom().nextLong() & Long.MAX_VALUE; + return new BigInteger(64, new SecureRandom()).longValue(); } @Override @@ -770,6 +758,9 @@ public class CallManager implements AutoCloseable { for (var callId : new ArrayList<>(activeCalls.keySet())) { endCall(callId, "shutdown"); } + synchronized (callEventListeners) { + callEventListeners.clear(); + } } // --- Internal call state tracking --- @@ -778,17 +769,15 @@ public class CallManager implements AutoCloseable { final long callId; volatile CallInfo.State state; - final org.asamk.signal.manager.api.RecipientAddress recipientAddress; - final RecipientIdentifier.Single recipientIdentifier; + final RecipientId recipientId; + volatile Integer deviceId; final boolean isOutgoing; volatile String inputDeviceName; volatile String outputDeviceName; volatile Process tunnelProcess; volatile PrintWriter controlWriter; - // Raw offer opaque for incoming calls (forwarded to subprocess) - volatile byte[] rawOfferOpaque; // Control messages queued before the tunnel process starts - final List pendingControlMessages = java.util.Collections.synchronizedList(new ArrayList<>()); + final List pendingControlMessages = Collections.synchronizedList(new ArrayList<>()); // Accept deferred until tunnel reports Ringing state volatile boolean acceptPending = false; // True once the tunnel has reported "Ringing" (ready to accept) @@ -797,19 +786,24 @@ public class CallManager implements AutoCloseable { CallState( long callId, CallInfo.State state, - org.asamk.signal.manager.api.RecipientAddress recipientAddress, - RecipientIdentifier.Single recipientIdentifier, + RecipientId recipientId, + final Integer deviceId, boolean isOutgoing ) { this.callId = callId; this.state = state; - this.recipientAddress = recipientAddress; - this.recipientIdentifier = recipientIdentifier; + this.recipientId = recipientId; + this.deviceId = deviceId; this.isOutgoing = isOutgoing; } - CallInfo toCallInfo() { - return new CallInfo(callId, state, recipientAddress, inputDeviceName, outputDeviceName, isOutgoing); + CallInfo toCallInfo(RecipientAddressResolver addressResolver) { + return new CallInfo(callId, + state, + addressResolver.resolveRecipientAddress(recipientId).toApiRecipientAddress(), + inputDeviceName, + outputDeviceName, + isOutgoing); } } } 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 ba22f49c..9750f96b 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 @@ -64,6 +64,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2; import org.whispersystems.signalservice.api.messages.SignalServicePniSignatureMessage; import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage; +import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; import org.whispersystems.signalservice.api.messages.multidevice.StickerPackOperationMessage; import org.whispersystems.signalservice.api.push.ServiceIdType; @@ -402,27 +403,33 @@ public final class IncomingMessageHandler { } if (content.getCallMessage().isPresent()) { - handleCallMessage(content.getCallMessage().get(), sender); + handleCallMessage(content.getCallMessage().get(), sender, senderDeviceId); } return new Pair<>(actions, longTexts); } private void handleCallMessage( - final org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage callMessage, - final org.asamk.signal.manager.storage.recipients.RecipientId sender + final SignalServiceCallMessage callMessage, + final RecipientId sender, + final int deviceId ) { var callManager = context.getCallManager(); + if (callMessage.getDestinationDeviceId().isPresent() + && callMessage.getDestinationDeviceId().get() != account.getDeviceId()) { + return; + } callMessage.getOfferMessage().ifPresent(offer -> { - var type = offer.getType() == org.whispersystems.signalservice.api.messages.calls.OfferMessage.Type.VIDEO_CALL + var type = offer.getType() + == org.whispersystems.signalservice.api.messages.calls.OfferMessage.Type.VIDEO_CALL ? org.asamk.signal.manager.api.MessageEnvelope.Call.Offer.Type.VIDEO_CALL : org.asamk.signal.manager.api.MessageEnvelope.Call.Offer.Type.AUDIO_CALL; - callManager.handleIncomingOffer(sender, offer.getId(), type, offer.getOpaque()); + callManager.handleIncomingOffer(sender, deviceId, offer.getId(), type, offer.getOpaque()); }); - callMessage.getAnswerMessage().ifPresent(answer -> - callManager.handleIncomingAnswer(answer.getId(), answer.getOpaque())); + callMessage.getAnswerMessage() + .ifPresent(answer -> callManager.handleIncomingAnswer(answer.getId(), deviceId, answer.getOpaque())); callMessage.getIceUpdateMessages().ifPresent(iceUpdates -> { for (var ice : iceUpdates) { @@ -440,8 +447,7 @@ public final class IncomingMessageHandler { } }); - callMessage.getBusyMessage().ifPresent(busy -> - callManager.handleIncomingBusy(busy.getId())); + callMessage.getBusyMessage().ifPresent(busy -> callManager.handleIncomingBusy(busy.getId())); } private boolean handlePniSignatureMessage( diff --git a/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java b/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java index 20d74da9..2c85bd59 100644 --- a/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java +++ b/lib/src/main/java/org/asamk/signal/manager/helper/SendHelper.java @@ -36,6 +36,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage; import org.whispersystems.signalservice.api.messages.SignalServiceEditMessage; import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage; import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage; +import org.whispersystems.signalservice.api.messages.calls.SignalServiceCallMessage; import org.whispersystems.signalservice.api.messages.multidevice.SentTranscriptMessage; import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage; import org.whispersystems.signalservice.api.push.DistributionId; @@ -309,6 +310,26 @@ public class SendHelper { return result; } + public SendMessageResult sendCallMessage( + final SignalServiceCallMessage callMessage, + final RecipientId recipientId + ) { + final var messageSendLogStore = account.getMessageSendLogStore(); + final var result = handleSendMessage(recipientId, + (messageSender, address, unidentifiedAccess, includePniSignature) -> messageSender.sendCallMessage( + address, + unidentifiedAccess, + callMessage)); + if (callMessage.getTimestamp().isPresent()) { + messageSendLogStore.insertIfPossible(callMessage.getTimestamp().get(), + result, + ContentHint.IMPLICIT, + callMessage.isUrgent()); + } + handleSendMessageResult(result); + return result; + } + private List sendAsGroupMessage( final SignalServiceDataMessage.Builder messageBuilder, final GroupInfo g, diff --git a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java index 2f4063d8..3a3da757 100644 --- a/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java +++ b/lib/src/main/java/org/asamk/signal/manager/internal/ManagerImpl.java @@ -21,7 +21,6 @@ import org.asamk.signal.manager.api.AlreadyReceivingException; import org.asamk.signal.manager.api.AttachmentInvalidException; import org.asamk.signal.manager.api.CallInfo; import org.asamk.signal.manager.api.CallOffer; -import org.asamk.signal.manager.api.TurnServer; import org.asamk.signal.manager.api.CaptchaRejectedException; import org.asamk.signal.manager.api.CaptchaRequiredException; import org.asamk.signal.manager.api.Configuration; @@ -65,6 +64,7 @@ import org.asamk.signal.manager.api.StickerPackId; import org.asamk.signal.manager.api.StickerPackInvalidException; import org.asamk.signal.manager.api.StickerPackUrl; import org.asamk.signal.manager.api.TextStyle; +import org.asamk.signal.manager.api.TurnServer; import org.asamk.signal.manager.api.TypingAction; import org.asamk.signal.manager.api.UnregisteredRecipientException; import org.asamk.signal.manager.api.UpdateGroup; @@ -172,7 +172,6 @@ public class ManagerImpl implements Manager { private boolean isReceivingSynchronous; private final Set weakHandlers = new HashSet<>(); private final Set messageHandlers = new HashSet<>(); - private final Set callEventListeners = new HashSet<>(); private final List closedListeners = new ArrayList<>(); private final List addressChangedListeners = new ArrayList<>(); private final CompositeDisposable disposable = new CompositeDisposable(); @@ -961,11 +960,9 @@ public class ManagerImpl implements Manager { final var messageBuilder = SignalServiceDataMessage.newBuilder().withRemoteDelete(delete); for (final var recipient : recipients) { if (recipient instanceof RecipientIdentifier.Uuid(var uuid)) { - account.getMessageSendLogStore() - .deleteEntryForRecipientNonGroup(targetSentTimestamp, ACI.from(uuid)); + account.getMessageSendLogStore().deleteEntryForRecipientNonGroup(targetSentTimestamp, ACI.from(uuid)); } else if (recipient instanceof RecipientIdentifier.Pni(var pni)) { - account.getMessageSendLogStore() - .deleteEntryForRecipientNonGroup(targetSentTimestamp, PNI.from(pni)); + account.getMessageSendLogStore().deleteEntryForRecipientNonGroup(targetSentTimestamp, PNI.from(pni)); } else if (recipient instanceof RecipientIdentifier.Single r) { try { final var recipientId = context.getRecipientHelper().resolveRecipient(r); @@ -1717,17 +1714,11 @@ public class ManagerImpl implements Manager { @Override public void addCallEventListener(final CallEventListener listener) { - synchronized (callEventListeners) { - callEventListeners.add(listener); - } context.getCallManager().addCallEventListener(listener); } @Override public void removeCallEventListener(final CallEventListener listener) { - synchronized (callEventListeners) { - callEventListeners.remove(listener); - } context.getCallManager().removeCallEventListener(listener); } @@ -1792,7 +1783,8 @@ public class ManagerImpl implements Manager { @Override public CallInfo startCall(final RecipientIdentifier.Single recipient) throws IOException, UnregisteredRecipientException { - return context.getCallManager().startOutgoingCall(recipient); + final var recipientId = context.getRecipientHelper().resolveRecipient(recipient); + return context.getCallManager().startOutgoingCall(recipientId); } @Override @@ -1806,8 +1798,9 @@ public class ManagerImpl implements Manager { } @Override - public void rejectCall(final long callId) throws IOException { - context.getCallManager().rejectCall(callId); + public SendMessageResult rejectCall(final long callId) throws IOException { + final var result = context.getCallManager().rejectCall(callId); + return toSendMessageResult(result); } @Override @@ -1858,9 +1851,7 @@ public class ManagerImpl implements Manager { ) throws IOException, UnregisteredRecipientException { final var recipientId = context.getRecipientHelper().resolveRecipient(recipient); final var address = context.getRecipientHelper().resolveSignalServiceAddress(recipientId); - var iceUpdates = iceCandidates.stream() - .map(opaque -> new IceUpdateMessage(callId, opaque)) - .toList(); + var iceUpdates = iceCandidates.stream().map(opaque -> new IceUpdateMessage(callId, opaque)).toList(); var callMessage = SignalServiceCallMessage.forIceUpdates(iceUpdates, null); try { dependencies.getMessageSender().sendCallMessage(address, null, callMessage); @@ -1926,12 +1917,6 @@ public class ManagerImpl implements Manager { if (thread != null) { stopReceiveThread(thread); } - synchronized (callEventListeners) { - for (var listener : callEventListeners) { - context.getCallManager().removeCallEventListener(listener); - } - callEventListeners.clear(); - } context.close(); executor.close(); diff --git a/lib/src/main/java/org/asamk/signal/manager/util/Utils.java b/lib/src/main/java/org/asamk/signal/manager/util/Utils.java index 89bc3962..aee133ab 100644 --- a/lib/src/main/java/org/asamk/signal/manager/util/Utils.java +++ b/lib/src/main/java/org/asamk/signal/manager/util/Utils.java @@ -18,6 +18,7 @@ import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; +import java.math.BigInteger; import java.net.Proxy; import java.net.ProxySelector; import java.net.URI; @@ -235,4 +236,11 @@ public class Utils { return proxies.getFirst(); } } + + /** + * Convert signed long call ID to unsigned BigInteger (tunnel binary expects u64). + */ + public static BigInteger callIdUnsigned(long callId) { + return new BigInteger(Long.toUnsignedString(callId)); + } } diff --git a/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerTest.java b/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerTest.java index 5d009aec..d7731411 100644 --- a/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerTest.java +++ b/lib/src/test/java/org/asamk/signal/manager/helper/CallManagerTest.java @@ -1,8 +1,8 @@ package org.asamk.signal.manager.helper; import org.asamk.signal.manager.api.CallInfo; -import org.asamk.signal.manager.api.RecipientAddress; - +import org.asamk.signal.manager.storage.recipients.TestRecipientId; +import org.asamk.signal.manager.util.Utils; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -29,17 +29,23 @@ class CallManagerTest { private static final MethodHandle CALL_ID_UNSIGNED; private static final MethodHandle GENERATE_CALL_ID; + final RecipientAddressResolver recipientAddressResolver = (id) -> new org.asamk.signal.manager.storage.recipients.RecipientAddress( + id.toString()); + static { try { var lookup = MethodHandles.privateLookupIn(CallManager.class, MethodHandles.lookup()); - GET_RAW_IDENTITY_KEY_BYTES = lookup.findStatic(CallManager.class, "getRawIdentityKeyBytes", + GET_RAW_IDENTITY_KEY_BYTES = lookup.findStatic(CallManager.class, + "getRawIdentityKeyBytes", MethodType.methodType(byte[].class, byte[].class)); - CALL_ID_UNSIGNED = lookup.findStatic(CallManager.class, "callIdUnsigned", + CALL_ID_UNSIGNED = lookup.findStatic(Utils.class, + "callIdUnsigned", MethodType.methodType(BigInteger.class, long.class)); - GENERATE_CALL_ID = lookup.findStatic(CallManager.class, "generateCallId", + GENERATE_CALL_ID = lookup.findStatic(CallManager.class, + "generateCallId", MethodType.methodType(long.class)); } catch (ReflectiveOperationException e) { @@ -62,14 +68,7 @@ class CallManagerTest { // --- Helper to create a minimal CallState for state machine tests --- private static CallManager.CallState makeCallState(long callId, CallInfo.State initialState) { - var address = new RecipientAddress("a1b2c3d4-e5f6-7890-abcd-ef1234567890", null, "+15551234567", null); - return new CallManager.CallState( - callId, - initialState, - address, - new org.asamk.signal.manager.api.RecipientIdentifier.Number("+15551234567"), - true - ); + return new CallManager.CallState(callId, initialState, TestRecipientId.createTestId(15551234567L), null, true); } // ======================================================================== @@ -165,14 +164,6 @@ class CallManagerTest { // generateCallId tests // ======================================================================== - @Test - void generateCallId_alwaysNonNegative() throws Throwable { - for (int i = 0; i < 200; i++) { - long id = generateCallId(); - assertTrue(id >= 0, "generateCallId returned negative: " + id); - } - } - @Test void generateCallId_producesVariation() throws Throwable { long first = generateCallId(); @@ -336,11 +327,11 @@ class CallManagerTest { state.inputDeviceName = "test_input"; state.outputDeviceName = "test_output"; - var info = state.toCallInfo(); + var info = state.toCallInfo(recipientAddressResolver); assertEquals(42L, info.callId()); assertEquals(CallInfo.State.CONNECTED, info.state()); - assertEquals("+15551234567", info.recipient().number().orElse(null)); + assertEquals("RecipientId[id=15551234567]", info.recipient().number().orElse(null)); assertTrue(info.isOutgoing()); assertEquals("test_input", info.inputDeviceName()); assertEquals("test_output", info.outputDeviceName()); @@ -350,7 +341,7 @@ class CallManagerTest { void callState_toCallInfoNullDeviceNames() { var state = makeCallState(1L, CallInfo.State.RINGING_INCOMING); - var info = state.toCallInfo(); + var info = state.toCallInfo(recipientAddressResolver); assertEquals(CallInfo.State.RINGING_INCOMING, info.state()); assertEquals(null, info.inputDeviceName()); diff --git a/lib/src/test/java/org/asamk/signal/manager/storage/recipients/TestRecipientId.java b/lib/src/test/java/org/asamk/signal/manager/storage/recipients/TestRecipientId.java new file mode 100644 index 00000000..682039d4 --- /dev/null +++ b/lib/src/test/java/org/asamk/signal/manager/storage/recipients/TestRecipientId.java @@ -0,0 +1,8 @@ +package org.asamk.signal.manager.storage.recipients; + +public class TestRecipientId { + + public static RecipientId createTestId(long value) { + return new RecipientId(value, null); + } +} diff --git a/src/main/java/org/asamk/signal/ReceiveMessageHandler.java b/src/main/java/org/asamk/signal/ReceiveMessageHandler.java index 18750c98..b0361551 100644 --- a/src/main/java/org/asamk/signal/ReceiveMessageHandler.java +++ b/src/main/java/org/asamk/signal/ReceiveMessageHandler.java @@ -15,6 +15,8 @@ import org.slf4j.helpers.MessageFormatter; import java.util.ArrayList; import java.util.stream.Collectors; +import static org.asamk.signal.manager.util.Utils.callIdUnsigned; + public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler { final Manager m; @@ -297,26 +299,32 @@ public class ReceiveMessageHandler implements Manager.ReceiveMessageHandler { } if (callMessage.answer().isPresent()) { var answerMessage = callMessage.answer().get(); - writer.println("Answer message: {}, opaque length: {})", answerMessage.id(), answerMessage.opaque().length); + writer.println("Answer message: {}, opaque length: {})", + callIdUnsigned(answerMessage.id()), + answerMessage.opaque().length); } if (callMessage.busy().isPresent()) { var busyMessage = callMessage.busy().get(); - writer.println("Busy message: {}", busyMessage.id()); + writer.println("Busy message: {}", callIdUnsigned(busyMessage.id())); } if (callMessage.hangup().isPresent()) { var hangupMessage = callMessage.hangup().get(); - writer.println("Hangup message: {}", hangupMessage.id()); + writer.println("Hangup message: {}", callIdUnsigned(hangupMessage.id())); } if (!callMessage.iceUpdate().isEmpty()) { writer.println("Ice update messages:"); var iceUpdateMessages = callMessage.iceUpdate(); for (var iceUpdateMessage : iceUpdateMessages) { - writer.println("- {}, opaque length: {}", iceUpdateMessage.id(), iceUpdateMessage.opaque().length); + writer.println("- {}, opaque length: {}", + callIdUnsigned(iceUpdateMessage.id()), + iceUpdateMessage.opaque().length); } } if (callMessage.offer().isPresent()) { var offerMessage = callMessage.offer().get(); - writer.println("Offer message: {}, opaque length: {}", offerMessage.id(), offerMessage.opaque().length); + writer.println("Offer message: {}, opaque length: {}", + callIdUnsigned(offerMessage.id()), + offerMessage.opaque().length); } if (callMessage.opaque().isPresent()) { final var opaqueMessage = callMessage.opaque().get(); diff --git a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java index 70a6f146..faf099e4 100644 --- a/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java +++ b/src/main/java/org/asamk/signal/dbus/DbusManagerImpl.java @@ -4,6 +4,8 @@ import org.asamk.Signal; import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.api.AlreadyReceivingException; import org.asamk.signal.manager.api.AttachmentInvalidException; +import org.asamk.signal.manager.api.CallInfo; +import org.asamk.signal.manager.api.CallOffer; import org.asamk.signal.manager.api.CaptchaRequiredException; import org.asamk.signal.manager.api.Configuration; import org.asamk.signal.manager.api.Contact; @@ -37,12 +39,14 @@ import org.asamk.signal.manager.api.Recipient; import org.asamk.signal.manager.api.RecipientAddress; import org.asamk.signal.manager.api.RecipientIdentifier; import org.asamk.signal.manager.api.SendGroupMessageResults; +import org.asamk.signal.manager.api.SendMessageResult; import org.asamk.signal.manager.api.SendMessageResults; import org.asamk.signal.manager.api.StickerPack; import org.asamk.signal.manager.api.StickerPackId; import org.asamk.signal.manager.api.StickerPackInvalidException; import org.asamk.signal.manager.api.StickerPackUrl; import org.asamk.signal.manager.api.TrustLevel; +import org.asamk.signal.manager.api.TurnServer; import org.asamk.signal.manager.api.TypingAction; import org.asamk.signal.manager.api.UnregisteredRecipientException; import org.asamk.signal.manager.api.UpdateGroup; @@ -925,12 +929,12 @@ public class DbusManagerImpl implements Manager { // --- Voice call methods (not supported over DBus) --- @Override - public org.asamk.signal.manager.api.CallInfo startCall(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient) { + public CallInfo startCall(final RecipientIdentifier.Single recipient) { throw new UnsupportedOperationException("Voice calls are not supported over DBus"); } @Override - public org.asamk.signal.manager.api.CallInfo acceptCall(final long callId) { + public CallInfo acceptCall(final long callId) { throw new UnsupportedOperationException("Voice calls are not supported over DBus"); } @@ -940,42 +944,54 @@ public class DbusManagerImpl implements Manager { } @Override - public void rejectCall(final long callId) { + public SendMessageResult rejectCall(final long callId) { throw new UnsupportedOperationException("Voice calls are not supported over DBus"); } @Override - public java.util.List listActiveCalls() { + public java.util.List listActiveCalls() { return java.util.List.of(); } @Override - public void sendCallOffer(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient, final org.asamk.signal.manager.api.CallOffer offer) { + public void sendCallOffer(final RecipientIdentifier.Single recipient, final CallOffer offer) { throw new UnsupportedOperationException("Voice calls are not supported over DBus"); } @Override - public void sendCallAnswer(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient, final long callId, final byte[] answerOpaque) { + public void sendCallAnswer( + final RecipientIdentifier.Single recipient, + final long callId, + final byte[] answerOpaque + ) { throw new UnsupportedOperationException("Voice calls are not supported over DBus"); } @Override - public void sendIceUpdate(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient, final long callId, final java.util.List iceCandidates) { + public void sendIceUpdate( + final RecipientIdentifier.Single recipient, + final long callId, + final java.util.List iceCandidates + ) { throw new UnsupportedOperationException("Voice calls are not supported over DBus"); } @Override - public void sendHangup(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient, final long callId, final org.asamk.signal.manager.api.MessageEnvelope.Call.Hangup.Type type) { + public void sendHangup( + final RecipientIdentifier.Single recipient, + final long callId, + final MessageEnvelope.Call.Hangup.Type type + ) { throw new UnsupportedOperationException("Voice calls are not supported over DBus"); } @Override - public void sendBusy(final org.asamk.signal.manager.api.RecipientIdentifier.Single recipient, final long callId) { + public void sendBusy(final RecipientIdentifier.Single recipient, final long callId) { throw new UnsupportedOperationException("Voice calls are not supported over DBus"); } @Override - public java.util.List getTurnServerInfo() { + public java.util.List getTurnServerInfo() { throw new UnsupportedOperationException("Voice calls are not supported over DBus"); } diff --git a/src/main/java/org/asamk/signal/json/JsonCallMessage.java b/src/main/java/org/asamk/signal/json/JsonCallMessage.java index 1b1bc2ba..c539cc46 100644 --- a/src/main/java/org/asamk/signal/json/JsonCallMessage.java +++ b/src/main/java/org/asamk/signal/json/JsonCallMessage.java @@ -4,9 +4,12 @@ import com.fasterxml.jackson.annotation.JsonInclude; import org.asamk.signal.manager.api.MessageEnvelope; +import java.math.BigInteger; import java.util.Base64; import java.util.List; +import static org.asamk.signal.manager.util.Utils.callIdUnsigned; + record JsonCallMessage( @JsonInclude(JsonInclude.Include.NON_NULL) Offer offerMessage, @JsonInclude(JsonInclude.Include.NON_NULL) Answer answerMessage, @@ -23,38 +26,41 @@ record JsonCallMessage( callMessage.iceUpdate().stream().map(IceUpdate::from).toList()); } - record Offer(long id, String type, String opaque) { + record Offer(BigInteger id, String type, String opaque) { public static Offer from(final MessageEnvelope.Call.Offer offer) { - return new Offer(offer.id(), offer.type().name(), Base64.getEncoder().encodeToString(offer.opaque())); + return new Offer(callIdUnsigned(offer.id()), + offer.type().name(), + Base64.getEncoder().encodeToString(offer.opaque())); } } - public record Answer(long id, String opaque) { + public record Answer(BigInteger id, String opaque) { public static Answer from(final MessageEnvelope.Call.Answer answer) { - return new Answer(answer.id(), Base64.getEncoder().encodeToString(answer.opaque())); + return new Answer(callIdUnsigned(answer.id()), Base64.getEncoder().encodeToString(answer.opaque())); } } - public record Busy(long id) { + public record Busy(BigInteger id) { public static Busy from(final MessageEnvelope.Call.Busy busy) { - return new Busy(busy.id()); + return new Busy(callIdUnsigned(busy.id())); } } - public record Hangup(long id, String type, int deviceId) { + public record Hangup(BigInteger id, String type, int deviceId) { public static Hangup from(final MessageEnvelope.Call.Hangup hangup) { - return new Hangup(hangup.id(), hangup.type().name(), hangup.deviceId()); + return new Hangup(callIdUnsigned(hangup.id()), hangup.type().name(), hangup.deviceId()); } } - public record IceUpdate(long id, String opaque) { + public record IceUpdate(BigInteger id, String opaque) { public static IceUpdate from(final MessageEnvelope.Call.IceUpdate iceUpdate) { - return new IceUpdate(iceUpdate.id(), Base64.getEncoder().encodeToString(iceUpdate.opaque())); + return new IceUpdate(callIdUnsigned(iceUpdate.id()), + Base64.getEncoder().encodeToString(iceUpdate.opaque())); } } } diff --git a/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json b/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json index 9a8f7b95..db19a986 100644 --- a/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json +++ b/src/main/resources/META-INF/native-image/org.asamk/signal-cli/reachability-metadata.json @@ -1082,6 +1082,12 @@ } ] }, + { + "type": "java.math.BigInteger" + }, + { + "type": "java.math.BigInteger[]" + }, { "type": "java.net.NetPermission" }, @@ -1977,10 +1983,18 @@ "name": "codec", "parameterTypes": [] }, + { + "name": "inputDeviceName", + "parameterTypes": [] + }, { "name": "mediaSocketPath", "parameterTypes": [] }, + { + "name": "outputDeviceName", + "parameterTypes": [] + }, { "name": "ptimeMs", "parameterTypes": [] @@ -2064,6 +2078,10 @@ "name": "callId", "parameterTypes": [] }, + { + "name": "inputDeviceName", + "parameterTypes": [] + }, { "name": "isOutgoing", "parameterTypes": [] @@ -2076,6 +2094,10 @@ "name": "number", "parameterTypes": [] }, + { + "name": "outputDeviceName", + "parameterTypes": [] + }, { "name": "state", "parameterTypes": [] @@ -2297,10 +2319,18 @@ "name": "codec", "parameterTypes": [] }, + { + "name": "inputDeviceName", + "parameterTypes": [] + }, { "name": "mediaSocketPath", "parameterTypes": [] }, + { + "name": "outputDeviceName", + "parameterTypes": [] + }, { "name": "ptimeMs", "parameterTypes": [] @@ -2421,6 +2451,43 @@ { "type": "org.asamk.signal.json.JsonAttachment[]" }, + { + "type": "org.asamk.signal.json.JsonCallEvent", + "methods": [ + { + "name": "callId", + "parameterTypes": [] + }, + { + "name": "inputDeviceName", + "parameterTypes": [] + }, + { + "name": "isOutgoing", + "parameterTypes": [] + }, + { + "name": "number", + "parameterTypes": [] + }, + { + "name": "outputDeviceName", + "parameterTypes": [] + }, + { + "name": "reason", + "parameterTypes": [] + }, + { + "name": "state", + "parameterTypes": [] + }, + { + "name": "uuid", + "parameterTypes": [] + } + ] + }, { "type": "org.asamk.signal.json.JsonCallMessage", "allDeclaredFields": true, @@ -7484,6 +7551,35 @@ "allDeclaredFields": true, "allDeclaredMethods": true }, + { + "type": "org.whispersystems.signalservice.api.messages.calls.TurnServerInfo", + "fields": [ + { + "name": "hostname" + }, + { + "name": "password" + }, + { + "name": "ttl" + }, + { + "name": "urls" + }, + { + "name": "urlsWithIps" + }, + { + "name": "username" + } + ], + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, { "type": "org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo", "allDeclaredFields": true, @@ -8064,6 +8160,17 @@ } ] }, + { + "type": "org.whispersystems.signalservice.internal.push.GetCallingRelaysResponse", + "methods": [ + { + "name": "", + "parameterTypes": [ + "java.util.List" + ] + } + ] + }, { "type": "org.whispersystems.signalservice.internal.push.GetUsernameFromLinkResponseBody", "allDeclaredFields": true, diff --git a/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java b/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java index 0e072eb6..48cbde22 100644 --- a/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java +++ b/src/test/java/org/asamk/signal/jsonrpc/SubscribeCallEventsTest.java @@ -2,25 +2,57 @@ package org.asamk.signal.jsonrpc; import org.asamk.signal.manager.Manager; import org.asamk.signal.manager.MultiAccountManager; -import org.asamk.signal.manager.RegistrationManager; import org.asamk.signal.manager.ProvisioningManager; -import org.asamk.signal.manager.api.*; +import org.asamk.signal.manager.RegistrationManager; +import org.asamk.signal.manager.api.CallInfo; +import org.asamk.signal.manager.api.CallOffer; +import org.asamk.signal.manager.api.Configuration; +import org.asamk.signal.manager.api.Device; +import org.asamk.signal.manager.api.DeviceLinkUrl; +import org.asamk.signal.manager.api.Group; +import org.asamk.signal.manager.api.GroupId; +import org.asamk.signal.manager.api.GroupInviteLinkUrl; +import org.asamk.signal.manager.api.Identity; +import org.asamk.signal.manager.api.IdentityVerificationCode; +import org.asamk.signal.manager.api.Message; +import org.asamk.signal.manager.api.MessageEnvelope; +import org.asamk.signal.manager.api.Pair; +import org.asamk.signal.manager.api.ReceiveConfig; +import org.asamk.signal.manager.api.Recipient; +import org.asamk.signal.manager.api.RecipientIdentifier; +import org.asamk.signal.manager.api.SendGroupMessageResults; +import org.asamk.signal.manager.api.SendMessageResult; +import org.asamk.signal.manager.api.SendMessageResults; +import org.asamk.signal.manager.api.StickerPack; +import org.asamk.signal.manager.api.StickerPackId; +import org.asamk.signal.manager.api.StickerPackUrl; +import org.asamk.signal.manager.api.TurnServer; +import org.asamk.signal.manager.api.TypingAction; +import org.asamk.signal.manager.api.UpdateGroup; +import org.asamk.signal.manager.api.UpdateProfile; +import org.asamk.signal.manager.api.UserStatus; +import org.asamk.signal.manager.api.UsernameLinkUrl; +import org.asamk.signal.manager.api.UsernameStatus; import org.asamk.signal.output.JsonWriter; - import org.junit.jupiter.api.Test; import java.io.File; -import java.io.IOException; import java.io.InputStream; import java.net.URI; import java.time.Duration; -import java.util.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Queue; +import java.util.Set; import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; /** * Tests for the subscribeCallEvents / unsubscribeCallEvents JSON-RPC commands @@ -32,6 +64,7 @@ class SubscribeCallEventsTest { * Feeds pre-configured JSON-RPC lines to the handler, then returns null to end. */ private static class LineFeeder { + private final Queue lines = new ConcurrentLinkedQueue<>(); void addLine(String line) { @@ -47,6 +80,7 @@ class SubscribeCallEventsTest { * Captures JSON-RPC responses written by the handler. */ private static class CapturingJsonWriter implements JsonWriter { + final List written = Collections.synchronizedList(new ArrayList<>()); @Override @@ -59,6 +93,7 @@ class SubscribeCallEventsTest { * Minimal Manager stub that tracks call event listener add/remove calls. */ private static class StubManager implements Manager { + final List listeners = new ArrayList<>(); final AtomicInteger addCount = new AtomicInteger(0); final AtomicInteger removeCount = new AtomicInteger(0); @@ -68,113 +103,483 @@ class SubscribeCallEventsTest { this.selfNumber = selfNumber; } - @Override public void addCallEventListener(CallEventListener listener) { + @Override + public void addCallEventListener(CallEventListener listener) { addCount.incrementAndGet(); listeners.add(listener); } - @Override public void removeCallEventListener(CallEventListener listener) { + @Override + public void removeCallEventListener(CallEventListener listener) { removeCount.incrementAndGet(); listeners.remove(listener); } - @Override public String getSelfNumber() { return selfNumber; } + @Override + public String getSelfNumber() { + return selfNumber; + } // --- Stubs for remaining Manager interface methods --- - @Override public Map getUserStatus(Set n) { return Map.of(); } - @Override public Map getUsernameStatus(Set u) { return Map.of(); } - @Override public void updateAccountAttributes(String d, Boolean u, Boolean dn, Boolean ns) {} - @Override public Configuration getConfiguration() { return null; } - @Override public void updateConfiguration(Configuration c) {} - @Override public void updateProfile(UpdateProfile u) {} - @Override public String getUsername() { return null; } - @Override public UsernameLinkUrl getUsernameLink() { return null; } - @Override public void setUsername(String u) {} - @Override public void deleteUsername() {} - @Override public void startChangeNumber(String n, boolean v, String c) {} - @Override public void finishChangeNumber(String n, String v, String p) {} - @Override public void unregister() {} - @Override public void deleteAccount() {} - @Override public void submitRateLimitRecaptchaChallenge(String c, String cap) {} - @Override public List getLinkedDevices() { return List.of(); } - @Override public void updateLinkedDevice(int d, String n) {} - @Override public void removeLinkedDevices(int d) {} - @Override public void addDeviceLink(DeviceLinkUrl u) {} - @Override public void setRegistrationLockPin(Optional p) {} - @Override public List getGroups() { return List.of(); } - @Override public List getGroups(Collection g) { return List.of(); } - @Override public SendGroupMessageResults quitGroup(GroupId g, Set a) { return null; } - @Override public void deleteGroup(GroupId g) {} - @Override public Pair createGroup(String n, Set m, String a) { return null; } - @Override public SendGroupMessageResults updateGroup(GroupId g, UpdateGroup u) { return null; } - @Override public Pair joinGroup(GroupInviteLinkUrl u) { return null; } - @Override public SendMessageResults sendTypingMessage(TypingAction a, Set r) { return null; } - @Override public SendMessageResults sendReadReceipt(RecipientIdentifier.Single s, List m) { return null; } - @Override public SendMessageResults sendViewedReceipt(RecipientIdentifier.Single s, List m) { return null; } - @Override public SendMessageResults sendMessage(Message m, Set r, boolean n) { return null; } - @Override public SendMessageResults sendEditMessage(Message m, Set r, long t) { return null; } - @Override public SendMessageResults sendRemoteDeleteMessage(long t, Set r) { return null; } - @Override public SendMessageResults sendMessageReaction(String e, boolean rm, RecipientIdentifier.Single a, long t, Set r, boolean n, boolean s) { return null; } - @Override public SendMessageResults sendAdminDelete(RecipientIdentifier.Single a, long t, Set r, boolean n, boolean s) { return null; } - @Override public SendMessageResults sendPinMessage(int d, RecipientIdentifier.Single a, long t, Set r, boolean n, boolean s) { return null; } - @Override public SendMessageResults sendUnpinMessage(RecipientIdentifier.Single a, long t, Set r, boolean n, boolean s) { return null; } - @Override public SendMessageResults sendPaymentNotificationMessage(byte[] r, String n, RecipientIdentifier.Single re) { return null; } - @Override public SendMessageResults sendEndSessionMessage(Set r) { return null; } - @Override public SendMessageResults sendMessageRequestResponse(MessageEnvelope.Sync.MessageRequestResponse.Type t, Set r) { return null; } - @Override public SendMessageResults sendPollCreateMessage(String q, boolean a, List o, Set r, boolean n) { return null; } - @Override public SendMessageResults sendPollVoteMessage(RecipientIdentifier.Single a, long t, List o, int v, Set r, boolean n) { return null; } - @Override public SendMessageResults sendPollTerminateMessage(long t, Set r, boolean n) { return null; } - @Override public void hideRecipient(RecipientIdentifier.Single r) {} - @Override public void deleteRecipient(RecipientIdentifier.Single r) {} - @Override public void deleteContact(RecipientIdentifier.Single r) {} - @Override public void setContactName(RecipientIdentifier.Single r, String g, String f, String ng, String nf, String n) {} - @Override public void setContactsBlocked(Collection r, boolean b) {} - @Override public void setGroupsBlocked(Collection g, boolean b) {} - @Override public void setExpirationTimer(RecipientIdentifier.Single r, int t) {} - @Override public StickerPackUrl uploadStickerPack(File p) { return null; } - @Override public void installStickerPack(StickerPackUrl u) {} - @Override public List getStickerPacks() { return List.of(); } - @Override public void requestAllSyncData() {} - @Override public void addReceiveHandler(ReceiveMessageHandler h, boolean w) {} - @Override public void removeReceiveHandler(ReceiveMessageHandler h) {} - @Override public boolean isReceiving() { return false; } - @Override public void receiveMessages(Optional t, Optional m, ReceiveMessageHandler h) {} - @Override public void stopReceiveMessages() {} - @Override public void setReceiveConfig(ReceiveConfig r) {} - @Override public boolean isContactBlocked(RecipientIdentifier.Single r) { return false; } - @Override public void sendContacts() {} - @Override public List getRecipients(boolean o, Optional b, Collection a, Optional n) { return List.of(); } - @Override public String getContactOrProfileName(RecipientIdentifier.Single r) { return null; } - @Override public Group getGroup(GroupId g) { return null; } - @Override public List getIdentities() { return List.of(); } - @Override public List getIdentities(RecipientIdentifier.Single r) { return List.of(); } - @Override public boolean trustIdentityVerified(RecipientIdentifier.Single r, IdentityVerificationCode v) { return false; } - @Override public boolean trustIdentityAllKeys(RecipientIdentifier.Single r) { return false; } - @Override public void addAddressChangedListener(Runnable l) {} - @Override public void addClosedListener(Runnable l) {} - @Override public InputStream retrieveAttachment(String id) { return null; } - @Override public InputStream retrieveContactAvatar(RecipientIdentifier.Single r) { return null; } - @Override public InputStream retrieveProfileAvatar(RecipientIdentifier.Single r) { return null; } - @Override public InputStream retrieveGroupAvatar(GroupId g) { return null; } - @Override public InputStream retrieveSticker(StickerPackId s, int i) { return null; } - @Override public CallInfo startCall(RecipientIdentifier.Single r) { return null; } - @Override public CallInfo acceptCall(long c) { return null; } - @Override public void hangupCall(long c) {} - @Override public void rejectCall(long c) {} - @Override public List listActiveCalls() { return List.of(); } - @Override public void sendCallOffer(RecipientIdentifier.Single r, CallOffer o) {} - @Override public void sendCallAnswer(RecipientIdentifier.Single r, long c, byte[] a) {} - @Override public void sendIceUpdate(RecipientIdentifier.Single r, long c, List i) {} - @Override public void sendHangup(RecipientIdentifier.Single r, long c, MessageEnvelope.Call.Hangup.Type t) {} - @Override public void sendBusy(RecipientIdentifier.Single r, long c) {} - @Override public List getTurnServerInfo() { return List.of(); } - @Override public void close() {} + @Override + public Map getUserStatus(Set n) { + return Map.of(); + } + + @Override + public Map getUsernameStatus(Set u) { + return Map.of(); + } + + @Override + public void updateAccountAttributes(String d, Boolean u, Boolean dn, Boolean ns) { + } + + @Override + public Configuration getConfiguration() { + return null; + } + + @Override + public void updateConfiguration(Configuration c) { + } + + @Override + public void updateProfile(UpdateProfile u) { + } + + @Override + public String getUsername() { + return null; + } + + @Override + public UsernameLinkUrl getUsernameLink() { + return null; + } + + @Override + public void setUsername(String u) { + } + + @Override + public void deleteUsername() { + } + + @Override + public void startChangeNumber(String n, boolean v, String c) { + } + + @Override + public void finishChangeNumber(String n, String v, String p) { + } + + @Override + public void unregister() { + } + + @Override + public void deleteAccount() { + } + + @Override + public void submitRateLimitRecaptchaChallenge(String c, String cap) { + } + + @Override + public List getLinkedDevices() { + return List.of(); + } + + @Override + public void updateLinkedDevice(int d, String n) { + } + + @Override + public void removeLinkedDevices(int d) { + } + + @Override + public void addDeviceLink(DeviceLinkUrl u) { + } + + @Override + public void setRegistrationLockPin(Optional p) { + } + + @Override + public List getGroups() { + return List.of(); + } + + @Override + public List getGroups(Collection g) { + return List.of(); + } + + @Override + public SendGroupMessageResults quitGroup(GroupId g, Set a) { + return null; + } + + @Override + public void deleteGroup(GroupId g) { + } + + @Override + public Pair createGroup( + String n, + Set m, + String a + ) { + return null; + } + + @Override + public SendGroupMessageResults updateGroup(GroupId g, UpdateGroup u) { + return null; + } + + @Override + public Pair joinGroup(GroupInviteLinkUrl u) { + return null; + } + + @Override + public SendMessageResults sendTypingMessage(TypingAction a, Set r) { + return null; + } + + @Override + public SendMessageResults sendReadReceipt(RecipientIdentifier.Single s, List m) { + return null; + } + + @Override + public SendMessageResults sendViewedReceipt(RecipientIdentifier.Single s, List m) { + return null; + } + + @Override + public SendMessageResults sendMessage(Message m, Set r, boolean n) { + return null; + } + + @Override + public SendMessageResults sendEditMessage(Message m, Set r, long t) { + return null; + } + + @Override + public SendMessageResults sendRemoteDeleteMessage(long t, Set r) { + return null; + } + + @Override + public SendMessageResults sendMessageReaction( + String e, + boolean rm, + RecipientIdentifier.Single a, + long t, + Set r, + boolean n, + boolean s + ) { + return null; + } + + @Override + public SendMessageResults sendAdminDelete( + RecipientIdentifier.Single a, + long t, + Set r, + boolean n, + boolean s + ) { + return null; + } + + @Override + public SendMessageResults sendPinMessage( + int d, + RecipientIdentifier.Single a, + long t, + Set r, + boolean n, + boolean s + ) { + return null; + } + + @Override + public SendMessageResults sendUnpinMessage( + RecipientIdentifier.Single a, + long t, + Set r, + boolean n, + boolean s + ) { + return null; + } + + @Override + public SendMessageResults sendPaymentNotificationMessage(byte[] r, String n, RecipientIdentifier.Single re) { + return null; + } + + @Override + public SendMessageResults sendEndSessionMessage(Set r) { + return null; + } + + @Override + public SendMessageResults sendMessageRequestResponse( + MessageEnvelope.Sync.MessageRequestResponse.Type t, + Set r + ) { + return null; + } + + @Override + public SendMessageResults sendPollCreateMessage( + String q, + boolean a, + List o, + Set r, + boolean n + ) { + return null; + } + + @Override + public SendMessageResults sendPollVoteMessage( + RecipientIdentifier.Single a, + long t, + List o, + int v, + Set r, + boolean n + ) { + return null; + } + + @Override + public SendMessageResults sendPollTerminateMessage(long t, Set r, boolean n) { + return null; + } + + @Override + public void hideRecipient(RecipientIdentifier.Single r) { + } + + @Override + public void deleteRecipient(RecipientIdentifier.Single r) { + } + + @Override + public void deleteContact(RecipientIdentifier.Single r) { + } + + @Override + public void setContactName(RecipientIdentifier.Single r, String g, String f, String ng, String nf, String n) { + } + + @Override + public void setContactsBlocked(Collection r, boolean b) { + } + + @Override + public void setGroupsBlocked(Collection g, boolean b) { + } + + @Override + public void setExpirationTimer(RecipientIdentifier.Single r, int t) { + } + + @Override + public StickerPackUrl uploadStickerPack(File p) { + return null; + } + + @Override + public void installStickerPack(StickerPackUrl u) { + } + + @Override + public List getStickerPacks() { + return List.of(); + } + + @Override + public void requestAllSyncData() { + } + + @Override + public void addReceiveHandler(ReceiveMessageHandler h, boolean w) { + } + + @Override + public void removeReceiveHandler(ReceiveMessageHandler h) { + } + + @Override + public boolean isReceiving() { + return false; + } + + @Override + public void receiveMessages(Optional t, Optional m, ReceiveMessageHandler h) { + } + + @Override + public void stopReceiveMessages() { + } + + @Override + public void setReceiveConfig(ReceiveConfig r) { + } + + @Override + public boolean isContactBlocked(RecipientIdentifier.Single r) { + return false; + } + + @Override + public void sendContacts() { + } + + @Override + public List getRecipients( + boolean o, + Optional b, + Collection a, + Optional n + ) { + return List.of(); + } + + @Override + public String getContactOrProfileName(RecipientIdentifier.Single r) { + return null; + } + + @Override + public Group getGroup(GroupId g) { + return null; + } + + @Override + public List getIdentities() { + return List.of(); + } + + @Override + public List getIdentities(RecipientIdentifier.Single r) { + return List.of(); + } + + @Override + public boolean trustIdentityVerified(RecipientIdentifier.Single r, IdentityVerificationCode v) { + return false; + } + + @Override + public boolean trustIdentityAllKeys(RecipientIdentifier.Single r) { + return false; + } + + @Override + public void addAddressChangedListener(Runnable l) { + } + + @Override + public void addClosedListener(Runnable l) { + } + + @Override + public InputStream retrieveAttachment(String id) { + return null; + } + + @Override + public InputStream retrieveContactAvatar(RecipientIdentifier.Single r) { + return null; + } + + @Override + public InputStream retrieveProfileAvatar(RecipientIdentifier.Single r) { + return null; + } + + @Override + public InputStream retrieveGroupAvatar(GroupId g) { + return null; + } + + @Override + public InputStream retrieveSticker(StickerPackId s, int i) { + return null; + } + + @Override + public CallInfo startCall(RecipientIdentifier.Single r) { + return null; + } + + @Override + public CallInfo acceptCall(long c) { + return null; + } + + @Override + public void hangupCall(long c) { + } + + @Override + public SendMessageResult rejectCall(long c) { + return null; + } + + @Override + public List listActiveCalls() { + return List.of(); + } + + @Override + public void sendCallOffer(RecipientIdentifier.Single r, CallOffer o) { + } + + @Override + public void sendCallAnswer(RecipientIdentifier.Single r, long c, byte[] a) { + } + + @Override + public void sendIceUpdate(RecipientIdentifier.Single r, long c, List i) { + } + + @Override + public void sendHangup(RecipientIdentifier.Single r, long c, MessageEnvelope.Call.Hangup.Type t) { + } + + @Override + public void sendBusy(RecipientIdentifier.Single r, long c) { + } + + @Override + public List getTurnServerInfo() { + return List.of(); + } + + @Override + public void close() { + } } /** * Minimal MultiAccountManager stub for multi-account mode tests. */ private static class StubMultiAccountManager implements MultiAccountManager { + final List managers; final List> addedHandlers = new ArrayList<>(); @@ -182,26 +587,48 @@ class SubscribeCallEventsTest { this.managers = new ArrayList<>(managers); } - @Override public List getAccountNumbers() { + @Override + public List getAccountNumbers() { return managers.stream().map(Manager::getSelfNumber).toList(); } - @Override public List getManagers() { return managers; } + @Override + public List getManagers() { + return managers; + } - @Override public void addOnManagerAddedHandler(Consumer handler) { + @Override + public void addOnManagerAddedHandler(Consumer handler) { addedHandlers.add(handler); } - @Override public void addOnManagerRemovedHandler(Consumer handler) {} + @Override + public void addOnManagerRemovedHandler(Consumer handler) { + } - @Override public Manager getManager(String phoneNumber) { + @Override + public Manager getManager(String phoneNumber) { return managers.stream().filter(m -> phoneNumber.equals(m.getSelfNumber())).findFirst().orElse(null); } - @Override public URI getNewProvisioningDeviceLinkUri() { return null; } - @Override public ProvisioningManager getProvisioningManagerFor(URI u) { return null; } - @Override public RegistrationManager getNewRegistrationManager(String a) { return null; } - @Override public void close() {} + @Override + public URI getNewProvisioningDeviceLinkUri() { + return null; + } + + @Override + public ProvisioningManager getProvisioningManagerFor(URI u) { + return null; + } + + @Override + public RegistrationManager getNewRegistrationManager(String a) { + return null; + } + + @Override + public void close() { + } } private static String jsonRpcCall(int id, String method) {